small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:24 +02:00
commit 5c500a72b0
243 changed files with 44176 additions and 0 deletions
@@ -0,0 +1,154 @@
package postgres
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/tdvorak/seen/backend/internal/domain"
)
var ErrUserAlreadyExists = errors.New("user already exists")
type AuthRepository struct {
pool *pgxpool.Pool
}
func NewAuthRepository(pool *pgxpool.Pool) *AuthRepository {
return &AuthRepository{pool: pool}
}
func (r *AuthRepository) CreateUser(ctx context.Context, user domain.User) error {
_, err := r.pool.Exec(
ctx,
`INSERT INTO users (id, email, display_name, role, password_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
user.ID,
strings.ToLower(strings.TrimSpace(user.Email)),
user.DisplayName,
user.Role,
user.PasswordHash,
user.CreatedAt,
user.UpdatedAt,
)
if err != nil && strings.Contains(err.Error(), "duplicate key") {
return ErrUserAlreadyExists
}
return err
}
func (r *AuthRepository) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
row := r.pool.QueryRow(
ctx,
`SELECT id, email, display_name, role, password_hash, created_at, updated_at
FROM users
WHERE email = $1`,
strings.ToLower(strings.TrimSpace(email)),
)
var user domain.User
if err := row.Scan(
&user.ID,
&user.Email,
&user.DisplayName,
&user.Role,
&user.PasswordHash,
&user.CreatedAt,
&user.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *AuthRepository) FindUserByID(ctx context.Context, userID uuid.UUID) (*domain.User, error) {
row := r.pool.QueryRow(
ctx,
`SELECT id, email, display_name, role, password_hash, created_at, updated_at
FROM users
WHERE id = $1`,
userID,
)
var user domain.User
if err := row.Scan(
&user.ID,
&user.Email,
&user.DisplayName,
&user.Role,
&user.PasswordHash,
&user.CreatedAt,
&user.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *AuthRepository) CreateSession(ctx context.Context, session domain.Session) error {
_, err := r.pool.Exec(
ctx,
`INSERT INTO sessions (id, user_id, refresh_token, user_agent, ip, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
session.ID,
session.UserID,
session.RefreshToken,
session.UserAgent,
session.IP,
session.ExpiresAt,
session.CreatedAt,
)
return err
}
func (r *AuthRepository) FindSessionByRefreshToken(ctx context.Context, refreshToken string) (*domain.Session, error) {
row := r.pool.QueryRow(
ctx,
`SELECT id, user_id, refresh_token, user_agent, ip, expires_at, revoked_at, created_at
FROM sessions
WHERE refresh_token = $1`,
refreshToken,
)
var session domain.Session
if err := row.Scan(
&session.ID,
&session.UserID,
&session.RefreshToken,
&session.UserAgent,
&session.IP,
&session.ExpiresAt,
&session.RevokedAt,
&session.CreatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &session, nil
}
func (r *AuthRepository) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
_, err := r.pool.Exec(
ctx,
`UPDATE sessions
SET revoked_at = now()
WHERE id = $1`,
sessionID,
)
return err
}
@@ -0,0 +1,701 @@
package postgres
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/tdvorak/seen/backend/internal/services/catalog"
)
const listDiscoverRowsSQL = `
WITH matched AS (
SELECT
ds.kind,
ds.title AS section_title,
ds.subtitle AS section_subtitle,
ds.display_order,
dsi.position AS section_position,
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date::text AS release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms,
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres
FROM discover_sections ds
JOIN discover_section_items dsi ON dsi.section_kind = ds.kind
JOIN media_items m ON m.id = dsi.media_id
LEFT JOIN media_genres mg ON mg.media_id = m.id
LEFT JOIN genres g ON g.id = mg.genre_id
WHERE
(
$1::text = ''
OR LOWER(m.title) LIKE '%' || LOWER($1::text) || '%'
OR LOWER(m.overview) LIKE '%' || LOWER($1::text) || '%'
OR EXISTS (
SELECT 1
FROM media_genres qmg
JOIN genres qg ON qg.id = qmg.genre_id
WHERE qmg.media_id = m.id
AND LOWER(qg.name) LIKE '%' || LOWER($1::text) || '%'
)
)
AND (
$2::text = ''
OR EXISTS (
SELECT 1
FROM media_genres gmg
JOIN genres gg ON gg.id = gmg.genre_id
WHERE gmg.media_id = m.id
AND LOWER(gg.name) = LOWER($2::text)
)
)
AND (
$3::text = ''
OR LOWER($3::text) = 'all'
OR m.media_type = LOWER($3::text)
)
GROUP BY
ds.kind,
ds.title,
ds.subtitle,
ds.display_order,
dsi.position,
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date,
m.rating,
m.runtime_minutes,
m.artwork_key
,
m.platforms
), ranked AS (
SELECT
*,
row_number() OVER (PARTITION BY kind ORDER BY section_position ASC) AS row_num
FROM matched
)
SELECT
kind,
section_title,
section_subtitle,
display_order,
section_position,
id,
provider,
provider_id,
title,
overview,
media_type,
release_date,
rating,
runtime_minutes,
artwork_key,
platforms,
genres
FROM ranked
WHERE row_num > $4::int
AND row_num <= ($4::int + $5::int)
ORDER BY display_order ASC, section_position ASC;
`
const listSectionItemsByKindSQL = `
SELECT
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date::text AS release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms,
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres
FROM discover_section_items dsi
JOIN media_items m ON m.id = dsi.media_id
LEFT JOIN media_genres mg ON mg.media_id = m.id
LEFT JOIN genres g ON g.id = mg.genre_id
WHERE dsi.section_kind = $1::text
GROUP BY
dsi.position,
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms
ORDER BY dsi.position ASC
LIMIT $2::int;
`
const searchMediaItemsSQL = `
SELECT
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date::text AS release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms,
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres
FROM media_items m
LEFT JOIN media_genres mg ON mg.media_id = m.id
LEFT JOIN genres g ON g.id = mg.genre_id
WHERE
(
$1::text = ''
OR LOWER(m.title) LIKE '%' || LOWER($1::text) || '%'
OR LOWER(m.overview) LIKE '%' || LOWER($1::text) || '%'
OR EXISTS (
SELECT 1
FROM media_genres qmg
JOIN genres qg ON qg.id = qmg.genre_id
WHERE qmg.media_id = m.id
AND LOWER(qg.name) LIKE '%' || LOWER($1::text) || '%'
)
)
AND (
$2::text = ''
OR EXISTS (
SELECT 1
FROM media_genres gmg
JOIN genres gg ON gg.id = gmg.genre_id
WHERE gmg.media_id = m.id
AND LOWER(gg.name) = LOWER($2::text)
)
)
AND (
$3::text = ''
OR LOWER($3::text) = 'all'
OR m.media_type = LOWER($3::text)
)
GROUP BY
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms
ORDER BY m.rating DESC, m.release_date DESC, m.title ASC
LIMIT 50;
`
const listUserWatchLaterSQL = `
SELECT
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date::text AS release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms,
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres,
uwl.created_at
FROM user_watch_later uwl
JOIN media_items m ON m.id = uwl.media_id
LEFT JOIN media_genres mg ON mg.media_id = m.id
LEFT JOIN genres g ON g.id = mg.genre_id
WHERE uwl.user_id = $1::uuid
GROUP BY
uwl.created_at,
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms
ORDER BY uwl.created_at DESC;
`
const addUserWatchLaterSQL = `
INSERT INTO user_watch_later (user_id, media_id)
VALUES ($1::uuid, $2::bigint)
ON CONFLICT (user_id, media_id) DO NOTHING;
`
const removeUserWatchLaterSQL = `
DELETE FROM user_watch_later
WHERE user_id = $1::uuid
AND media_id = $2::bigint;
`
const mediaExistsSQL = `
SELECT EXISTS(SELECT 1 FROM media_items WHERE id = $1::bigint);
`
const listContinueWatchingSQL = `
SELECT
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date::text AS release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms,
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres,
up.season_number,
up.episode_number,
up.progress_percent,
up.last_watched_at
FROM user_progress up
JOIN media_items m ON m.id = up.media_id
LEFT JOIN media_genres mg ON mg.media_id = m.id
LEFT JOIN genres g ON g.id = mg.genre_id
WHERE up.user_id = $1::uuid
AND up.progress_percent > 0
AND up.progress_percent < 100
GROUP BY
up.last_watched_at,
up.season_number,
up.episode_number,
up.progress_percent,
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms
ORDER BY up.last_watched_at DESC
LIMIT $2::int;
`
const upsertUserProgressSQL = `
INSERT INTO user_progress (
user_id,
media_id,
season_number,
episode_number,
progress_percent,
last_watched_at,
created_at,
updated_at
) VALUES ($1::uuid, $2::bigint, $3::int, $4::int, $5::int, NOW(), NOW(), NOW())
ON CONFLICT (user_id, media_id, season_number, episode_number)
DO UPDATE SET
progress_percent = EXCLUDED.progress_percent,
last_watched_at = NOW(),
updated_at = NOW();
`
type CatalogRepository struct {
pool *pgxpool.Pool
}
func NewCatalogRepository(pool *pgxpool.Pool) *CatalogRepository {
return &CatalogRepository{pool: pool}
}
type scannedMediaItem struct {
id int64
provider string
providerID int64
title string
overview string
mediaType string
releaseDate string
rating float64
runtimeMinutes int32
artworkKey string
platforms []string
genres []string
}
func (item scannedMediaItem) toCatalog() catalog.MediaItem {
return catalog.MediaItem{
ID: int(item.id),
Provider: catalog.MediaProvider(item.provider),
ProviderID: int(item.providerID),
Title: item.title,
Overview: item.overview,
Type: catalog.MediaType(item.mediaType),
ReleaseDate: item.releaseDate,
Genres: cloneStrings(item.genres),
Platforms: cloneStrings(item.platforms),
Rating: item.rating,
RuntimeMinutes: int(item.runtimeMinutes),
ArtworkKey: item.artworkKey,
}
}
func (r *CatalogRepository) Discover(
ctx context.Context,
params catalog.DiscoverParams,
) ([]catalog.DiscoverSection, error) {
offset := (params.Page - 1) * params.PageSize
rows, err := r.pool.Query(
ctx,
listDiscoverRowsSQL,
strings.TrimSpace(params.Query),
strings.TrimSpace(params.Genre),
strings.TrimSpace(params.MediaType),
offset,
params.PageSize,
)
if err != nil {
return nil, err
}
defer rows.Close()
sections := make([]catalog.DiscoverSection, 0)
sectionIndex := make(map[string]int)
for rows.Next() {
var kind string
var sectionTitle string
var sectionSubtitle string
var displayOrder int16
var sectionPosition int16
var item scannedMediaItem
if err := rows.Scan(
&kind,
&sectionTitle,
&sectionSubtitle,
&displayOrder,
&sectionPosition,
&item.id,
&item.provider,
&item.providerID,
&item.title,
&item.overview,
&item.mediaType,
&item.releaseDate,
&item.rating,
&item.runtimeMinutes,
&item.artworkKey,
&item.platforms,
&item.genres,
); err != nil {
return nil, err
}
_ = displayOrder
_ = sectionPosition
idx, exists := sectionIndex[kind]
if !exists {
idx = len(sections)
sectionIndex[kind] = idx
sections = append(sections, catalog.DiscoverSection{
Kind: kind,
Title: sectionTitle,
Subtitle: sectionSubtitle,
Items: []catalog.MediaItem{},
})
}
sections[idx].Items = append(sections[idx].Items, item.toCatalog())
}
if err := rows.Err(); err != nil {
return nil, err
}
return sections, nil
}
func (r *CatalogRepository) SectionItems(
ctx context.Context,
kind string,
limit int,
) ([]catalog.MediaItem, error) {
rows, err := r.pool.Query(ctx, listSectionItemsByKindSQL, strings.TrimSpace(kind), limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]catalog.MediaItem, 0, limit)
for rows.Next() {
var item scannedMediaItem
if err := rows.Scan(
&item.id,
&item.provider,
&item.providerID,
&item.title,
&item.overview,
&item.mediaType,
&item.releaseDate,
&item.rating,
&item.runtimeMinutes,
&item.artworkKey,
&item.platforms,
&item.genres,
); err != nil {
return nil, err
}
items = append(items, item.toCatalog())
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (r *CatalogRepository) SearchMedia(
ctx context.Context,
params catalog.SearchParams,
) ([]catalog.MediaItem, error) {
query := strings.TrimSpace(params.Query)
if query == "" {
return []catalog.MediaItem{}, nil
}
rows, err := r.pool.Query(
ctx,
searchMediaItemsSQL,
query,
strings.TrimSpace(params.Genre),
strings.TrimSpace(params.MediaType),
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]catalog.MediaItem, 0, 32)
for rows.Next() {
var item scannedMediaItem
if err := rows.Scan(
&item.id,
&item.provider,
&item.providerID,
&item.title,
&item.overview,
&item.mediaType,
&item.releaseDate,
&item.rating,
&item.runtimeMinutes,
&item.artworkKey,
&item.platforms,
&item.genres,
); err != nil {
return nil, err
}
items = append(items, item.toCatalog())
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (r *CatalogRepository) ListWatchLater(
ctx context.Context,
userID uuid.UUID,
) ([]catalog.MediaItem, error) {
rows, err := r.pool.Query(ctx, listUserWatchLaterSQL, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]catalog.MediaItem, 0, 8)
for rows.Next() {
var item scannedMediaItem
var createdAt time.Time
if err := rows.Scan(
&item.id,
&item.provider,
&item.providerID,
&item.title,
&item.overview,
&item.mediaType,
&item.releaseDate,
&item.rating,
&item.runtimeMinutes,
&item.artworkKey,
&item.platforms,
&item.genres,
&createdAt,
); err != nil {
return nil, err
}
_ = createdAt
items = append(items, item.toCatalog())
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (r *CatalogRepository) AddWatchLater(
ctx context.Context,
userID uuid.UUID,
mediaID int,
) error {
if err := r.ensureMediaExists(ctx, mediaID); err != nil {
return err
}
_, err := r.pool.Exec(ctx, addUserWatchLaterSQL, userID, mediaID)
return err
}
func (r *CatalogRepository) RemoveWatchLater(
ctx context.Context,
userID uuid.UUID,
mediaID int,
) error {
_, err := r.pool.Exec(ctx, removeUserWatchLaterSQL, userID, mediaID)
return err
}
func (r *CatalogRepository) ListContinueWatching(
ctx context.Context,
userID uuid.UUID,
limit int,
) ([]catalog.ContinueWatchingItem, error) {
rows, err := r.pool.Query(ctx, listContinueWatchingSQL, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]catalog.ContinueWatchingItem, 0, limit)
for rows.Next() {
var item scannedMediaItem
var seasonNumber int32
var episodeNumber int32
var progressPercent int32
var lastWatchedAt time.Time
if err := rows.Scan(
&item.id,
&item.provider,
&item.providerID,
&item.title,
&item.overview,
&item.mediaType,
&item.releaseDate,
&item.rating,
&item.runtimeMinutes,
&item.artworkKey,
&item.platforms,
&item.genres,
&seasonNumber,
&episodeNumber,
&progressPercent,
&lastWatchedAt,
); err != nil {
return nil, err
}
items = append(items, catalog.ContinueWatchingItem{
Item: item.toCatalog(),
Progress: catalog.EpisodeProgress{
ItemID: int(item.id),
SeasonNumber: int(seasonNumber),
EpisodeNumber: int(episodeNumber),
ProgressPercent: int(progressPercent),
LastWatchedAt: lastWatchedAt.UTC().Format(time.RFC3339),
},
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (r *CatalogRepository) UpsertProgress(
ctx context.Context,
userID uuid.UUID,
input catalog.ProgressUpdateInput,
) error {
if err := r.ensureMediaExists(ctx, input.MediaID); err != nil {
return err
}
_, err := r.pool.Exec(
ctx,
upsertUserProgressSQL,
userID,
input.MediaID,
input.SeasonNumber,
input.EpisodeNumber,
input.ProgressPercent,
)
return err
}
func (r *CatalogRepository) ensureMediaExists(ctx context.Context, mediaID int) error {
var exists bool
if err := r.pool.QueryRow(ctx, mediaExistsSQL, mediaID).Scan(&exists); err != nil {
return err
}
if !exists {
return catalog.ErrMediaNotFound
}
return nil
}
func cloneStrings(values []string) []string {
if len(values) == 0 {
return []string{}
}
cloned := make([]string, len(values))
copy(cloned, values)
return cloned
}
@@ -0,0 +1,506 @@
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
}
@@ -0,0 +1,37 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/tdvorak/seen/backend/internal/config"
"go.uber.org/zap"
)
func NewPool(ctx context.Context, cfg config.PostgresConfig, log *zap.Logger) (*pgxpool.Pool, error) {
poolConfig, err := pgxpool.ParseConfig(cfg.URL)
if err != nil {
return nil, fmt.Errorf("parse postgres url: %w", err)
}
poolConfig.MaxConns = cfg.MaxConns
poolConfig.MinConns = cfg.MinConns
poolConfig.MaxConnIdleTime = 5 * time.Minute
poolConfig.HealthCheckPeriod = 30 * time.Second
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("connect postgres: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
log.Info("postgres connected", zap.Int32("max_conns", cfg.MaxConns), zap.Int32("min_conns", cfg.MinConns))
return pool, nil
}