package db import ( "context" "database/sql" "errors" "fmt" "strings" ) type CreateReleaseParams struct { ProjectID int64 Version string Build string Filename string StoragePath string ChecksumSHA256 string SizeBytes int64 ContentType string ReleaseNotes string UploadedByUserID *int64 IsActive bool } func (r *ReleaseRepository) Create(ctx context.Context, params CreateReleaseParams) (*Release, error) { isActive := 0 if params.IsActive { isActive = 1 } result, err := r.q.ExecContext( ctx, `INSERT INTO releases ( project_id, version, build, filename, storage_path, checksum_sha256, size_bytes, content_type, release_notes, uploaded_by_user_id, is_active ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, params.ProjectID, strings.TrimSpace(params.Version), strings.TrimSpace(params.Build), strings.TrimSpace(params.Filename), strings.TrimSpace(params.StoragePath), strings.TrimSpace(params.ChecksumSHA256), params.SizeBytes, strings.TrimSpace(params.ContentType), strings.TrimSpace(params.ReleaseNotes), params.UploadedByUserID, isActive, ) if err != nil { if isUniqueConstraintError(err) { return nil, errors.Join(ErrConflict, fmt.Errorf("insert release: %w", err)) } return nil, fmt.Errorf("insert release: %w", err) } releaseID, err := result.LastInsertId() if err != nil { return nil, fmt.Errorf("load inserted release id: %w", err) } return r.GetByID(ctx, releaseID) } func (r *ReleaseRepository) GetByID(ctx context.Context, id int64) (*Release, error) { release, err := scanRelease(r.q.QueryRowContext( ctx, `SELECT id, project_id, version, build, filename, storage_path, checksum_sha256, size_bytes, content_type, release_notes, created_at, updated_at, uploaded_by_user_id, is_active FROM releases WHERE id = ?`, id, )) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("scan release by id: %w", err) } return release, nil } func (r *ReleaseRepository) GetLatestByProjectID(ctx context.Context, projectID int64) (*Release, error) { release, err := scanRelease(r.q.QueryRowContext( ctx, `SELECT id, project_id, version, build, filename, storage_path, checksum_sha256, size_bytes, content_type, release_notes, created_at, updated_at, uploaded_by_user_id, is_active FROM releases WHERE project_id = ? AND is_active = 1 ORDER BY created_at DESC, id DESC LIMIT 1`, projectID, )) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("scan latest release by project id: %w", err) } return release, nil } func (r *ReleaseRepository) ListByProjectID(ctx context.Context, projectID int64) ([]ReleaseListItem, error) { rows, err := r.q.QueryContext( ctx, `SELECT r.id, r.project_id, r.version, r.build, r.filename, r.storage_path, r.checksum_sha256, r.size_bytes, r.content_type, r.release_notes, r.created_at, r.updated_at, r.uploaded_by_user_id, r.is_active, COALESCE(u.email, '') FROM releases AS r LEFT JOIN users AS u ON u.id = r.uploaded_by_user_id WHERE r.project_id = ? ORDER BY r.created_at DESC, r.id DESC`, projectID, ) if err != nil { return nil, fmt.Errorf("query project releases: %w", err) } defer rows.Close() releases := make([]ReleaseListItem, 0) for rows.Next() { item, err := scanReleaseListItem(rows) if err != nil { return nil, fmt.Errorf("scan project release: %w", err) } releases = append(releases, *item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate project releases: %w", err) } return releases, nil } func scanRelease(scanner rowScanner) (*Release, error) { var ( release Release createdAtRaw string updatedAtRaw string uploadedByUserID sql.NullInt64 isActive int ) if err := scanner.Scan( &release.ID, &release.ProjectID, &release.Version, &release.Build, &release.Filename, &release.StoragePath, &release.ChecksumSHA256, &release.SizeBytes, &release.ContentType, &release.ReleaseNotes, &createdAtRaw, &updatedAtRaw, &uploadedByUserID, &isActive, ); err != nil { return nil, err } createdAt, err := parseTimestamp(createdAtRaw) if err != nil { return nil, fmt.Errorf("parse release created_at: %w", err) } updatedAt, err := parseTimestamp(updatedAtRaw) if err != nil { return nil, fmt.Errorf("parse release updated_at: %w", err) } release.CreatedAt = createdAt release.UpdatedAt = updatedAt release.IsActive = isActive == 1 if uploadedByUserID.Valid { release.UploadedByUserID = &uploadedByUserID.Int64 } return &release, nil } func scanReleaseListItem(scanner rowScanner) (*ReleaseListItem, error) { var ( item ReleaseListItem createdAtRaw string updatedAtRaw string uploadedByUserID sql.NullInt64 isActive int ) if err := scanner.Scan( &item.Release.ID, &item.Release.ProjectID, &item.Release.Version, &item.Release.Build, &item.Release.Filename, &item.Release.StoragePath, &item.Release.ChecksumSHA256, &item.Release.SizeBytes, &item.Release.ContentType, &item.Release.ReleaseNotes, &createdAtRaw, &updatedAtRaw, &uploadedByUserID, &isActive, &item.UploadedByEmail, ); err != nil { return nil, err } createdAt, err := parseTimestamp(createdAtRaw) if err != nil { return nil, fmt.Errorf("parse release list created_at: %w", err) } updatedAt, err := parseTimestamp(updatedAtRaw) if err != nil { return nil, fmt.Errorf("parse release list updated_at: %w", err) } item.Release.CreatedAt = createdAt item.Release.UpdatedAt = updatedAt item.Release.IsActive = isActive == 1 if uploadedByUserID.Valid { item.Release.UploadedByUserID = &uploadedByUserID.Int64 } return &item, nil }