persona-community-1/services/persona-api/internal/adapter/postgres/media.go
2026-02-23 10:21:29 +00:00

185 lines
5.7 KiB
Go

package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/jmoiron/sqlx"
"git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain"
"git.threesix.ai/jordan/persona-community-1/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
}