package postgres import ( "context" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/tdvorak/seen/backend/internal/services/download" ) const createDownloadJobSQL = ` INSERT INTO download_jobs ( user_id, source_type, source, title, status, queue_position, progress_percent, bytes_total, bytes_downloaded, download_speed_mbps, eta_seconds, error_message, retry_count, created_at, updated_at ) VALUES ( $1::uuid, $2::text, $3::text, COALESCE(NULLIF($4::text, ''), $3::text), 'queued', NULL, 0, 0, 0, 0, NULL, '', 0, NOW(), NOW() ) RETURNING id::text, user_id::text, source_type, source, title, status, COALESCE(queue_position, 0), progress_percent, bytes_total, bytes_downloaded, download_speed_mbps, COALESCE(eta_seconds, 0), error_message, retry_count, created_at, updated_at; ` const listDownloadJobsSQL = ` SELECT id::text, user_id::text, source_type, source, title, status, COALESCE(queue_position, 0), progress_percent, bytes_total, bytes_downloaded, download_speed_mbps, COALESCE(eta_seconds, 0), error_message, retry_count, created_at, updated_at, COALESCE(started_at, NOW()), COALESCE(completed_at, NOW()), COALESCE(cancelled_at, NOW()) FROM download_jobs WHERE user_id = $1::uuid AND ($2::text = '' OR status = $2::text) ORDER BY updated_at DESC, created_at DESC LIMIT $3::int OFFSET $4::int; ` const getDownloadJobByIDSQL = ` SELECT id::text, user_id::text, source_type, source, title, status, COALESCE(queue_position, 0), progress_percent, bytes_total, bytes_downloaded, download_speed_mbps, COALESCE(eta_seconds, 0), error_message, retry_count, created_at, updated_at, COALESCE(started_at, NOW()), COALESCE(completed_at, NOW()), COALESCE(cancelled_at, NOW()) FROM download_jobs WHERE user_id = $1::uuid AND id = $2::uuid LIMIT 1; ` const cancelDownloadJobSQL = ` UPDATE download_jobs SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW() WHERE user_id = $1::uuid AND id = $2::uuid RETURNING id::text, user_id::text, source_type, source, title, status, COALESCE(queue_position, 0), progress_percent, bytes_total, bytes_downloaded, download_speed_mbps, COALESCE(eta_seconds, 0), error_message, retry_count, created_at, updated_at, COALESCE(started_at, NOW()), COALESCE(completed_at, NOW()), COALESCE(cancelled_at, NOW()); ` const appendDownloadEventSQL = ` INSERT INTO download_events ( job_id, status, message, progress_percent, payload, created_at ) VALUES ( $1::uuid, $2::text, $3::text, $4::int, $5::jsonb, NOW() ); ` const updateDownloadJobSQL = ` UPDATE download_jobs SET status = $3::text, progress_percent = $4::int, bytes_total = $5::bigint, bytes_downloaded = $6::bigint, download_speed_mbps = $7::double precision, eta_seconds = CASE WHEN $8::int <= 0 THEN NULL ELSE $8::int END, error_message = $9::text, retry_count = $10::smallint, started_at = CASE WHEN $3::text IN ('preparing', 'downloading') AND started_at IS NULL THEN NOW() ELSE started_at END, completed_at = CASE WHEN $3::text = 'completed' THEN NOW() ELSE completed_at END, cancelled_at = CASE WHEN $3::text = 'cancelled' THEN NOW() ELSE cancelled_at END, updated_at = NOW() WHERE user_id = $1::uuid AND id = $2::uuid RETURNING id::text, user_id::text, source_type, source, title, status, COALESCE(queue_position, 0), progress_percent, bytes_total, bytes_downloaded, download_speed_mbps, COALESCE(eta_seconds, 0), error_message, retry_count, created_at, updated_at, COALESCE(started_at, NOW()), COALESCE(completed_at, NOW()), COALESCE(cancelled_at, NOW()); ` const listDownloadEventsSQL = ` SELECT id, job_id::text, status, message, progress_percent, payload::text, created_at FROM download_events WHERE job_id = $1::uuid AND ($2::timestamptz IS NULL OR created_at > $2::timestamptz) ORDER BY created_at DESC LIMIT $3::int; ` type DownloadRepository struct { pool *pgxpool.Pool } func NewDownloadRepository(pool *pgxpool.Pool) *DownloadRepository { return &DownloadRepository{pool: pool} } func (r *DownloadRepository) CreateJob( ctx context.Context, userID uuid.UUID, input download.CreateInput, ) (download.Job, error) { var job download.Job err := r.pool.QueryRow( ctx, createDownloadJobSQL, userID, input.SourceType, input.Source, input.Title, ).Scan( &job.ID, &job.UserID, &job.SourceType, &job.Source, &job.Title, &job.Status, &job.QueuePosition, &job.ProgressPercent, &job.BytesTotal, &job.BytesDownloaded, &job.DownloadSpeedMbps, &job.EtaSeconds, &job.ErrorMessage, &job.RetryCount, &job.CreatedAt, &job.UpdatedAt, ) if err != nil { return download.Job{}, err } return job, nil } func (r *DownloadRepository) ListJobs( ctx context.Context, userID uuid.UUID, params download.ListParams, ) ([]download.Job, error) { rows, err := r.pool.Query( ctx, listDownloadJobsSQL, userID, params.Status, params.Limit, params.Offset, ) if err != nil { return nil, err } defer rows.Close() jobs := make([]download.Job, 0, params.Limit) for rows.Next() { var job download.Job if err := rows.Scan( &job.ID, &job.UserID, &job.SourceType, &job.Source, &job.Title, &job.Status, &job.QueuePosition, &job.ProgressPercent, &job.BytesTotal, &job.BytesDownloaded, &job.DownloadSpeedMbps, &job.EtaSeconds, &job.ErrorMessage, &job.RetryCount, &job.CreatedAt, &job.UpdatedAt, &job.StartedAt, &job.CompletedAt, &job.CancelledAt, ); err != nil { return nil, err } jobs = append(jobs, job) } return jobs, rows.Err() } func (r *DownloadRepository) GetJobByID( ctx context.Context, userID uuid.UUID, jobID string, ) (download.Job, error) { var job download.Job err := r.pool.QueryRow(ctx, getDownloadJobByIDSQL, userID, jobID).Scan( &job.ID, &job.UserID, &job.SourceType, &job.Source, &job.Title, &job.Status, &job.QueuePosition, &job.ProgressPercent, &job.BytesTotal, &job.BytesDownloaded, &job.DownloadSpeedMbps, &job.EtaSeconds, &job.ErrorMessage, &job.RetryCount, &job.CreatedAt, &job.UpdatedAt, &job.StartedAt, &job.CompletedAt, &job.CancelledAt, ) if err != nil { return download.Job{}, download.ErrNotFound } return job, nil } func (r *DownloadRepository) CancelJob( ctx context.Context, userID uuid.UUID, jobID string, ) (download.Job, error) { var job download.Job err := r.pool.QueryRow(ctx, cancelDownloadJobSQL, userID, jobID).Scan( &job.ID, &job.UserID, &job.SourceType, &job.Source, &job.Title, &job.Status, &job.QueuePosition, &job.ProgressPercent, &job.BytesTotal, &job.BytesDownloaded, &job.DownloadSpeedMbps, &job.EtaSeconds, &job.ErrorMessage, &job.RetryCount, &job.CreatedAt, &job.UpdatedAt, &job.StartedAt, &job.CompletedAt, &job.CancelledAt, ) if err != nil { return download.Job{}, download.ErrNotFound } return job, nil } func (r *DownloadRepository) ListEvents( ctx context.Context, userID uuid.UUID, jobID string, params download.EventParams, ) ([]download.Event, error) { var after pgtype.Timestamptz if params.After != "" { t, err := time.Parse(time.RFC3339, params.After) if err == nil { after = pgtype.Timestamptz{ Time: t, Valid: true, } } } rows, err := r.pool.Query(ctx, listDownloadEventsSQL, jobID, after, params.Limit) if err != nil { return nil, err } defer rows.Close() events := make([]download.Event, 0, params.Limit) for rows.Next() { var event download.Event if err := rows.Scan( &event.ID, &event.JobID, &event.Status, &event.Message, &event.ProgressPercent, &event.Payload, &event.CreatedAt, ); err != nil { return nil, err } events = append(events, event) } return events, rows.Err() } func (r *DownloadRepository) AppendEvent( ctx context.Context, jobID string, event download.Event, ) error { _, err := r.pool.Exec( ctx, appendDownloadEventSQL, jobID, event.Status, event.Message, event.ProgressPercent, event.Payload, ) return err } func (r *DownloadRepository) UpdateJob( ctx context.Context, userID uuid.UUID, jobID string, input download.UpdateInput, ) (download.Job, error) { var job download.Job err := r.pool.QueryRow( ctx, updateDownloadJobSQL, userID, jobID, input.Status, input.ProgressPercent, input.BytesTotal, input.BytesDownloaded, input.DownloadSpeedMbps, input.EtaSeconds, input.ErrorMessage, input.RetryCount, ).Scan( &job.ID, &job.UserID, &job.SourceType, &job.Source, &job.Title, &job.Status, &job.QueuePosition, &job.ProgressPercent, &job.BytesTotal, &job.BytesDownloaded, &job.DownloadSpeedMbps, &job.EtaSeconds, &job.ErrorMessage, &job.RetryCount, &job.CreatedAt, &job.UpdatedAt, &job.StartedAt, &job.CompletedAt, &job.CancelledAt, ) if err != nil { return download.Job{}, download.ErrNotFound } return job, nil }