package postgres import ( "context" "database/sql" "errors" "fmt" "time" "github.com/jmoiron/sqlx" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain" "git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port" ) // Compile-time interface check. var _ port.MediaRepository = (*MediaObjectRepository)(nil) // mediaObjectRow maps to the media_objects table. type mediaObjectRow struct { ID string `db:"id"` UserID string `db:"user_id"` Path string `db:"path"` Filename string `db:"filename"` ContentType string `db:"content_type"` Size int64 `db:"size"` GenerationJobID string `db:"generation_job_id"` DeletedAt *time.Time `db:"deleted_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } func (r *mediaObjectRow) toDomain() *domain.MediaObject { return &domain.MediaObject{ ID: domain.MediaObjectID(r.ID), UserID: domain.UserID(r.UserID), Path: r.Path, Filename: r.Filename, ContentType: r.ContentType, Size: r.Size, GenerationJobID: r.GenerationJobID, DeletedAt: r.DeletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, } } // MediaObjectRepository implements port.MediaRepository with PostgreSQL/CockroachDB. type MediaObjectRepository struct { db *sqlx.DB } // NewMediaObjectRepository creates a new Postgres-backed media repository. func NewMediaObjectRepository(db *sqlx.DB) *MediaObjectRepository { return &MediaObjectRepository{db: db} } func (r *MediaObjectRepository) Create(ctx context.Context, obj *domain.MediaObject) error { _, err := r.db.ExecContext(ctx, ` INSERT INTO media_objects (id, user_id, path, filename, content_type, size, generation_job_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `, string(obj.ID), string(obj.UserID), obj.Path, obj.Filename, obj.ContentType, obj.Size, obj.GenerationJobID, obj.CreatedAt, obj.UpdatedAt) if err != nil { return fmt.Errorf("insert media object: %w", err) } return nil } func (r *MediaObjectRepository) Get(ctx context.Context, id domain.MediaObjectID) (*domain.MediaObject, error) { var row mediaObjectRow err := r.db.GetContext(ctx, &row, ` SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at FROM media_objects WHERE id = $1 AND deleted_at IS NULL `, string(id)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrNotFound } return nil, fmt.Errorf("get media object: %w", err) } return row.toDomain(), nil } func (r *MediaObjectRepository) ListByUser(ctx context.Context, userID domain.UserID, opts port.ListMediaOptions) ([]domain.MediaObject, int, error) { limit := opts.Limit if limit <= 0 { limit = 50 } // Count total matching records countQuery := `SELECT COUNT(*) FROM media_objects WHERE user_id = $1 AND deleted_at IS NULL` args := []any{string(userID)} argIdx := 2 if opts.ContentTypePrefix != "" { countQuery += fmt.Sprintf(` AND content_type LIKE $%d`, argIdx) args = append(args, opts.ContentTypePrefix+"%") argIdx++ } var total int if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil { return nil, 0, fmt.Errorf("count media objects: %w", err) } // Fetch paginated results query := ` SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at FROM media_objects WHERE user_id = $1 AND deleted_at IS NULL` fetchArgs := []any{string(userID)} fetchIdx := 2 if opts.ContentTypePrefix != "" { query += fmt.Sprintf(` AND content_type LIKE $%d`, fetchIdx) fetchArgs = append(fetchArgs, opts.ContentTypePrefix+"%") fetchIdx++ } query += ` ORDER BY created_at DESC` query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, fetchIdx, fetchIdx+1) fetchArgs = append(fetchArgs, limit, opts.Offset) var rows []mediaObjectRow if err := r.db.SelectContext(ctx, &rows, query, fetchArgs...); err != nil { return nil, 0, fmt.Errorf("list media objects: %w", err) } objects := make([]domain.MediaObject, len(rows)) for i := range rows { objects[i] = *rows[i].toDomain() } return objects, total, nil } func (r *MediaObjectRepository) SoftDelete(ctx context.Context, id domain.MediaObjectID) error { result, err := r.db.ExecContext(ctx, ` UPDATE media_objects SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL `, string(id)) if err != nil { return fmt.Errorf("soft delete media object: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("soft delete rows affected: %w", err) } if rows == 0 { return domain.ErrNotFound } return nil } func (r *MediaObjectRepository) HardDelete(ctx context.Context, id domain.MediaObjectID) error { result, err := r.db.ExecContext(ctx, `DELETE FROM media_objects WHERE id = $1`, string(id)) if err != nil { return fmt.Errorf("hard delete media object: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("hard delete rows affected: %w", err) } if rows == 0 { return domain.ErrNotFound } return nil } func (r *MediaObjectRepository) GetByPath(ctx context.Context, path string) (*domain.MediaObject, error) { var row mediaObjectRow err := r.db.GetContext(ctx, &row, ` SELECT id, user_id, path, filename, content_type, size, generation_job_id, deleted_at, created_at, updated_at FROM media_objects WHERE path = $1 AND deleted_at IS NULL `, path) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrNotFound } return nil, fmt.Errorf("get media object by path: %w", err) } return row.toDomain(), nil }