Add photo galleries API and frontend for public and admin viewing.

Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-07-25 17:32:23 +00:00
co-authored by Cursor
parent 4772b85f3d
commit c9a600b6d6
61 changed files with 6912 additions and 7 deletions
+113
View File
@@ -0,0 +1,113 @@
package store
// Every query against Drizzle-owned tables (users, events, tickets,
// payments) lives in this file so the read-coupling surface stays small
// and auditable. Column semantics follow backend/src/db/schema.ts.
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
type UserAuth struct {
ID string
Role string
TokenVersion int
AccountStatus string
}
func (db *DB) GetUserAuth(ctx context.Context, userID string) (UserAuth, error) {
row := db.QueryRowContext(ctx, db.Rebind(
"SELECT id, role, token_version, account_status FROM users WHERE id = ?"), userID)
var id, role, tv, status any
if err := row.Scan(&id, &role, &tv, &status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return UserAuth{}, ErrNotFound
}
return UserAuth{}, err
}
return UserAuth{
ID: asString(id),
Role: asString(role),
TokenVersion: int(asInt(tv)),
AccountStatus: asString(status),
}, nil
}
// HasPaidTicket implements the review decision: a confirmed/checked-in
// ticket whose payment is 'paid', or any confirmed/checked-in ticket when
// the event is free (price = 0).
func (db *DB) HasPaidTicket(ctx context.Context, userID, eventID string) (bool, error) {
var v any
err := db.QueryRowContext(ctx, db.Rebind(`
SELECT 1
FROM tickets t
JOIN events e ON e.id = t.event_id
LEFT JOIN payments p ON p.ticket_id = t.id
WHERE t.user_id = ? AND t.event_id = ?
AND t.status IN ('confirmed','checked_in')
AND (p.status = 'paid' OR e.price = 0)
LIMIT 1`), userID, eventID).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
type EventSummary struct {
ID string
Slug string
Title string
TitleEs string
StartDatetime time.Time
Status string
}
func (db *DB) GetEventSummary(ctx context.Context, eventID string) (EventSummary, error) {
return db.getEventWhere(ctx, "id = ?", eventID)
}
// GetEventSummaryBySlug resolves the /events/{slug}/gallery public route.
func (db *DB) GetEventSummaryBySlug(ctx context.Context, slug string) (EventSummary, error) {
return db.getEventWhere(ctx, "slug = ?", slug)
}
func (db *DB) getEventWhere(ctx context.Context, where string, arg any) (EventSummary, error) {
row := db.QueryRowContext(ctx, db.Rebind(
"SELECT id, slug, title, title_es, start_datetime, status FROM events WHERE "+where), arg)
var id, slug, title, titleEs, start, status any
if err := row.Scan(&id, &slug, &title, &titleEs, &start, &status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return EventSummary{}, ErrNotFound
}
return EventSummary{}, err
}
return EventSummary{
ID: asString(id),
Slug: asString(slug),
Title: asString(title),
TitleEs: asString(titleEs),
StartDatetime: asTime(start),
Status: asString(status),
}, nil
}
// CheckMainSchema fails fast at startup if the columns this service reads
// from Drizzle-owned tables have been renamed or dropped.
func (db *DB) CheckMainSchema(ctx context.Context) error {
checks := []string{
"SELECT id, role, token_version, account_status FROM users WHERE 1 = 0",
"SELECT id, slug, title, title_es, start_datetime, status, price FROM events WHERE 1 = 0",
"SELECT id, user_id, event_id, status FROM tickets WHERE 1 = 0",
"SELECT id, ticket_id, status FROM payments WHERE 1 = 0",
}
for _, q := range checks {
if _, err := db.ExecContext(ctx, q); err != nil {
return fmt.Errorf("main app schema check failed (%s): %w", q, err)
}
}
return nil
}
+122
View File
@@ -0,0 +1,122 @@
// Package store owns all database access. Photo tables use the photos_
// prefix and are migrated by this service alone; the handful of reads
// against Drizzle-owned tables (users, events, tickets, payments) are
// confined to access.go.
package store
import (
"database/sql"
"fmt"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
)
const (
Postgres = "postgres"
SQLite = "sqlite"
// timeLayout is a fixed-width UTC format so SQLite text timestamps
// sort correctly; the backend stores ISO strings in SQLite the same way.
timeLayout = "2006-01-02T15:04:05.000Z"
)
type DB struct {
*sql.DB
Type string
}
func Open(cfg config.Config) (*DB, error) {
switch cfg.DBType {
case Postgres:
sqlDB, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
sqlDB.SetMaxOpenConns(10)
return &DB{DB: sqlDB, Type: Postgres}, nil
case SQLite:
dsn := "file:" + strings.TrimPrefix(cfg.DatabaseURL, "file:") +
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// One writer at a time keeps the shared file friendly to the backend.
sqlDB.SetMaxOpenConns(1)
return &DB{DB: sqlDB, Type: SQLite}, nil
default:
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
}
}
// Rebind converts ?-style placeholders to $n for Postgres. Queries in this
// package never contain literal question marks in strings.
func (db *DB) Rebind(query string) string {
if db.Type != Postgres {
return query
}
var b strings.Builder
n := 0
for _, r := range query {
if r == '?' {
n++
fmt.Fprintf(&b, "$%d", n)
} else {
b.WriteRune(r)
}
}
return b.String()
}
// TimeArg converts a time for binding: time.Time for Postgres (timestamptz
// columns), fixed-layout UTC text for SQLite (text columns).
func (db *DB) TimeArg(t time.Time) any {
if db.Type == Postgres {
return t.UTC()
}
return t.UTC().Format(timeLayout)
}
// asString normalizes scanned values across drivers.
func asString(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case []byte:
return string(x)
case time.Time:
return x.UTC().Format(timeLayout)
default:
return fmt.Sprintf("%v", x)
}
}
// asTime parses a scanned timestamp from either driver; zero time on failure.
func asTime(v any) time.Time {
switch x := v.(type) {
case time.Time:
return x.UTC()
case string:
return parseTime(x)
case []byte:
return parseTime(string(x))
default:
return time.Time{}
}
}
func parseTime(s string) time.Time {
for _, layout := range []string{timeLayout, time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
+289
View File
@@ -0,0 +1,289 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
var ErrNotFound = errors.New("not found")
const (
VisibilityPublic = "public"
VisibilityPrivate = "private"
VisibilityLink = "link"
VisibilityTicket = "ticket"
)
type Gallery struct {
ID string
Slug string
Title string
TitleEs string
Description string
DescriptionEs string
EventID string
Visibility string
ShareToken string
CoverPhotoID string
CreatedBy string
CreatedAt time.Time
UpdatedAt time.Time
PhotoCount int
}
const galleryColumns = `g.id, g.slug, g.title, g.title_es, g.description, g.description_es,
g.event_id, g.visibility, g.share_token, g.cover_photo_id, g.created_by, g.created_at, g.updated_at`
type scanner interface{ Scan(...any) error }
func scanGallery(s scanner, withCount bool) (Gallery, error) {
var v [13]any
dest := make([]any, 0, 14)
for i := range v {
dest = append(dest, &v[i])
}
var count any
if withCount {
dest = append(dest, &count)
}
if err := s.Scan(dest...); err != nil {
return Gallery{}, err
}
g := Gallery{
ID: asString(v[0]),
Slug: asString(v[1]),
Title: asString(v[2]),
TitleEs: asString(v[3]),
Description: asString(v[4]),
DescriptionEs: asString(v[5]),
EventID: asString(v[6]),
Visibility: asString(v[7]),
ShareToken: asString(v[8]),
CoverPhotoID: asString(v[9]),
CreatedBy: asString(v[10]),
CreatedAt: asTime(v[11]),
UpdatedAt: asTime(v[12]),
}
if withCount {
g.PhotoCount = int(asInt(count))
}
return g, nil
}
func (db *DB) CreateGallery(ctx context.Context, g Gallery) error {
_, err := db.ExecContext(ctx, db.Rebind(`
INSERT INTO photos_galleries
(id, slug, title, title_es, description, description_es, event_id, visibility, share_token, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
g.ID, g.Slug, g.Title, nullable(g.TitleEs), nullable(g.Description), nullable(g.DescriptionEs),
nullable(g.EventID), g.Visibility, g.ShareToken, nullable(g.CreatedBy),
db.TimeArg(g.CreatedAt), db.TimeArg(g.UpdatedAt))
return err
}
func (db *DB) GetGallery(ctx context.Context, id string) (Gallery, error) {
return db.getGalleryWhere(ctx, "g.id = ?", id)
}
func (db *DB) GetGalleryBySlug(ctx context.Context, slug string) (Gallery, error) {
return db.getGalleryWhere(ctx, "g.slug = ?", slug)
}
// GetGalleryByEventID returns the newest gallery linked to an event, for
// the /events/{slug}/gallery public route.
func (db *DB) GetGalleryByEventID(ctx context.Context, eventID string) (Gallery, error) {
galleries, err := db.queryGalleries(ctx, `
SELECT `+galleryColumns+`,
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count
FROM photos_galleries g
WHERE g.event_id = ?
ORDER BY g.created_at DESC LIMIT 1`, eventID)
if err != nil {
return Gallery{}, err
}
if len(galleries) == 0 {
return Gallery{}, ErrNotFound
}
return galleries[0], nil
}
func (db *DB) getGalleryWhere(ctx context.Context, where string, arg any) (Gallery, error) {
row := db.QueryRowContext(ctx, db.Rebind(`
SELECT `+galleryColumns+`,
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count
FROM photos_galleries g WHERE `+where), arg)
g, err := scanGallery(row, true)
if errors.Is(err, sql.ErrNoRows) {
return Gallery{}, ErrNotFound
}
return g, err
}
// ListGalleries returns every gallery (admin view). eventID filters when set.
func (db *DB) ListGalleries(ctx context.Context, eventID string, limit, offset int) ([]Gallery, error) {
q := `
SELECT ` + galleryColumns + `,
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count
FROM photos_galleries g`
args := []any{}
if eventID != "" {
q += " WHERE g.event_id = ?"
args = append(args, eventID)
}
q += " ORDER BY g.created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
return db.queryGalleries(ctx, q, args...)
}
// ListPublicGalleries returns visibility='public' galleries with at least
// their ready photo counts, newest first.
func (db *DB) ListPublicGalleries(ctx context.Context) ([]Gallery, error) {
return db.queryGalleries(ctx, `
SELECT `+galleryColumns+`,
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id AND p.status = 'ready') AS photo_count
FROM photos_galleries g
WHERE g.visibility = 'public'
ORDER BY g.created_at DESC`)
}
func (db *DB) queryGalleries(ctx context.Context, q string, args ...any) ([]Gallery, error) {
rows, err := db.QueryContext(ctx, db.Rebind(q), args...)
if err != nil {
return nil, err
}
defer rows.Close()
galleries := []Gallery{}
for rows.Next() {
g, err := scanGallery(rows, true)
if err != nil {
return nil, err
}
galleries = append(galleries, g)
}
return galleries, rows.Err()
}
type GalleryUpdate struct {
Title *string
TitleEs *string
Description *string
DescriptionEs *string
EventID *string // empty string clears the link
Visibility *string
CoverPhotoID *string
}
func (db *DB) UpdateGallery(ctx context.Context, id string, u GalleryUpdate) error {
set := ""
args := []any{}
add := func(col string, val any) {
if set != "" {
set += ", "
}
set += col + " = ?"
args = append(args, val)
}
if u.Title != nil {
add("title", *u.Title)
}
if u.TitleEs != nil {
add("title_es", nullable(*u.TitleEs))
}
if u.Description != nil {
add("description", nullable(*u.Description))
}
if u.DescriptionEs != nil {
add("description_es", nullable(*u.DescriptionEs))
}
if u.EventID != nil {
add("event_id", nullable(*u.EventID))
}
if u.Visibility != nil {
add("visibility", *u.Visibility)
}
if u.CoverPhotoID != nil {
add("cover_photo_id", nullable(*u.CoverPhotoID))
}
if set == "" {
return nil
}
add("updated_at", db.TimeArg(time.Now()))
args = append(args, id)
res, err := db.ExecContext(ctx, db.Rebind("UPDATE photos_galleries SET "+set+" WHERE id = ?"), args...)
if err != nil {
return err
}
return errIfNoRows(res)
}
func (db *DB) RotateShareToken(ctx context.Context, id, token string) error {
res, err := db.ExecContext(ctx,
db.Rebind("UPDATE photos_galleries SET share_token = ?, updated_at = ? WHERE id = ?"),
token, db.TimeArg(time.Now()), id)
if err != nil {
return err
}
return errIfNoRows(res)
}
func (db *DB) DeleteGallery(ctx context.Context, id string) error {
res, err := db.ExecContext(ctx, db.Rebind("DELETE FROM photos_galleries WHERE id = ?"), id)
if err != nil {
return err
}
return errIfNoRows(res)
}
func (db *DB) SlugExists(ctx context.Context, slug string) (bool, error) {
var v any
err := db.QueryRowContext(ctx, db.Rebind("SELECT 1 FROM photos_galleries WHERE slug = ?"), slug).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func errIfNoRows(res sql.Result) error {
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// nullable maps "" to NULL so optional text columns stay NULL not ”.
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
func asInt(v any) int64 {
switch x := v.(type) {
case int64:
return x
case int:
return int64(x)
case int32:
return int64(x)
case float64:
return int64(x)
case []byte:
var n int64
for _, c := range x {
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
default:
return 0
}
}
+115
View File
@@ -0,0 +1,115 @@
package store
import (
"context"
"fmt"
"log"
"sort"
"strconv"
"strings"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/migrations"
)
// advisoryLockKey is an arbitrary fixed key for pg_advisory_lock so two
// instances can't run migrations concurrently. SQLite relies on its file lock.
const advisoryLockKey = 792346801
// Migrate applies embedded migrations for the active dialect in version
// order, recording applied versions in photos_schema_migrations.
func (db *DB) Migrate(ctx context.Context) error {
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
if db.Type == Postgres {
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", advisoryLockKey); err != nil {
return fmt.Errorf("advisory lock: %w", err)
}
defer conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", advisoryLockKey)
}
createVersions := "CREATE TABLE IF NOT EXISTS photos_schema_migrations (version integer PRIMARY KEY, applied_at text NOT NULL)"
if db.Type == Postgres {
createVersions = "CREATE TABLE IF NOT EXISTS photos_schema_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL)"
}
if _, err := conn.ExecContext(ctx, createVersions); err != nil {
return fmt.Errorf("create photos_schema_migrations: %w", err)
}
applied := map[int]bool{}
rows, err := conn.QueryContext(ctx, "SELECT version FROM photos_schema_migrations")
if err != nil {
return err
}
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
rows.Close()
return err
}
applied[v] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
suffix := ".sqlite.sql"
if db.Type == Postgres {
suffix = ".pg.sql"
}
entries, err := migrations.FS.ReadDir(".")
if err != nil {
return err
}
var names []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), suffix) {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
version, err := strconv.Atoi(strings.SplitN(name, "_", 2)[0])
if err != nil {
return fmt.Errorf("migration %s: name must start with a numeric version", name)
}
if applied[version] {
continue
}
body, err := migrations.FS.ReadFile(name)
if err != nil {
return err
}
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return err
}
for _, stmt := range strings.Split(string(body), ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := tx.ExecContext(ctx, stmt); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s: %w", name, err)
}
}
if _, err := tx.ExecContext(ctx, db.Rebind("INSERT INTO photos_schema_migrations (version, applied_at) VALUES (?, ?)"),
version, db.TimeArg(time.Now())); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
log.Printf("applied migration %s", name)
}
return nil
}
+271
View File
@@ -0,0 +1,271 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
const maxAttempts = 5
type Photo struct {
ID string
GalleryID string
Position int
OriginalKey string
OriginalFilename string
ContentType string
SizeBytes int64
Width int
Height int
ThumbKey string
PreviewKey string
TakenAt time.Time
Status string
Attempts int
NextAttemptAt time.Time
LastError string
CreatedAt time.Time
UpdatedAt time.Time
}
const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type,
size_bytes, width, height, thumb_key, preview_key, taken_at, status, attempts, next_attempt_at,
last_error, created_at, updated_at`
func scanPhoto(s scanner) (Photo, error) {
var v [18]any
dest := make([]any, len(v))
for i := range v {
dest[i] = &v[i]
}
if err := s.Scan(dest...); err != nil {
return Photo{}, err
}
return Photo{
ID: asString(v[0]),
GalleryID: asString(v[1]),
Position: int(asInt(v[2])),
OriginalKey: asString(v[3]),
OriginalFilename: asString(v[4]),
ContentType: asString(v[5]),
SizeBytes: asInt(v[6]),
Width: int(asInt(v[7])),
Height: int(asInt(v[8])),
ThumbKey: asString(v[9]),
PreviewKey: asString(v[10]),
TakenAt: asTime(v[11]),
Status: asString(v[12]),
Attempts: int(asInt(v[13])),
NextAttemptAt: asTime(v[14]),
LastError: asString(v[15]),
CreatedAt: asTime(v[16]),
UpdatedAt: asTime(v[17]),
}, nil
}
func (db *DB) InsertPhoto(ctx context.Context, p Photo) error {
_, err := db.ExecContext(ctx, db.Rebind(`
INSERT INTO photos_photos
(id, gallery_id, position, original_key, original_filename, content_type, size_bytes,
status, attempts, next_attempt_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`),
p.ID, p.GalleryID, p.Position, p.OriginalKey, nullable(p.OriginalFilename), p.ContentType,
p.SizeBytes, db.TimeArg(p.NextAttemptAt), db.TimeArg(p.CreatedAt), db.TimeArg(p.UpdatedAt))
return err
}
func (db *DB) GetPhoto(ctx context.Context, id string) (Photo, error) {
row := db.QueryRowContext(ctx,
db.Rebind("SELECT "+photoColumns+" FROM photos_photos WHERE id = ?"), id)
p, err := scanPhoto(row)
if errors.Is(err, sql.ErrNoRows) {
return Photo{}, ErrNotFound
}
return p, err
}
// ListPhotos returns a gallery's photos in position order. When readyOnly is
// set (public callers), photos still processing or failed are omitted.
func (db *DB) ListPhotos(ctx context.Context, galleryID string, readyOnly bool) ([]Photo, error) {
q := "SELECT " + photoColumns + " FROM photos_photos WHERE gallery_id = ?"
if readyOnly {
q += " AND status = 'ready'"
}
q += " ORDER BY position, created_at"
rows, err := db.QueryContext(ctx, db.Rebind(q), galleryID)
if err != nil {
return nil, err
}
defer rows.Close()
photos := []Photo{}
for rows.Next() {
p, err := scanPhoto(rows)
if err != nil {
return nil, err
}
photos = append(photos, p)
}
return photos, rows.Err()
}
func (db *DB) NextPosition(ctx context.Context, galleryID string) (int, error) {
var v any
err := db.QueryRowContext(ctx,
db.Rebind("SELECT COALESCE(MAX(position), -1) + 1 FROM photos_photos WHERE gallery_id = ?"),
galleryID).Scan(&v)
return int(asInt(v)), err
}
// ReorderPhotos rewrites positions to match ids order, in one transaction.
// ids must be exactly the gallery's photo ids (validated by the handler).
func (db *DB) ReorderPhotos(ctx context.Context, galleryID string, ids []string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := db.Rebind("UPDATE photos_photos SET position = ?, updated_at = ? WHERE id = ? AND gallery_id = ?")
now := db.TimeArg(time.Now())
for i, id := range ids {
if _, err := tx.ExecContext(ctx, q, i, now, id, galleryID); err != nil {
return err
}
}
return tx.Commit()
}
func (db *DB) DeletePhoto(ctx context.Context, id string) error {
res, err := db.ExecContext(ctx, db.Rebind("DELETE FROM photos_photos WHERE id = ?"), id)
if err != nil {
return err
}
return errIfNoRows(res)
}
// PhotoKeys returns every storage key of a gallery, for object cleanup
// before the rows cascade away.
func (db *DB) PhotoKeys(ctx context.Context, galleryID string) ([]string, error) {
rows, err := db.QueryContext(ctx, db.Rebind(
"SELECT original_key, thumb_key, preview_key FROM photos_photos WHERE gallery_id = ?"), galleryID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var a, b, c any
if err := rows.Scan(&a, &b, &c); err != nil {
return nil, err
}
for _, k := range []string{asString(a), asString(b), asString(c)} {
if k != "" {
keys = append(keys, k)
}
}
}
return keys, rows.Err()
}
// ClaimNextPhoto picks the oldest due queued/failed photo and marks it
// processing. Optimistic claim (RowsAffected check) works identically on
// Postgres and SQLite; returns ErrNotFound when the queue is empty.
func (db *DB) ClaimNextPhoto(ctx context.Context) (Photo, error) {
now := time.Now()
var idRaw any
err := db.QueryRowContext(ctx, db.Rebind(`
SELECT id FROM photos_photos
WHERE status IN ('queued','failed') AND attempts < ? AND next_attempt_at <= ?
ORDER BY created_at LIMIT 1`),
maxAttempts, db.TimeArg(now)).Scan(&idRaw)
if errors.Is(err, sql.ErrNoRows) {
return Photo{}, ErrNotFound
}
if err != nil {
return Photo{}, err
}
id := asString(idRaw)
res, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'processing', attempts = attempts + 1, updated_at = ?
WHERE id = ? AND status IN ('queued','failed')`),
db.TimeArg(now), id)
if err != nil {
return Photo{}, err
}
if n, _ := res.RowsAffected(); n == 0 {
return Photo{}, ErrNotFound // lost the race; caller loops again
}
return db.GetPhoto(ctx, id)
}
func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, width, height int, takenAt time.Time) error {
var takenArg any
if !takenAt.IsZero() {
takenArg = db.TimeArg(takenAt)
}
_, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos
SET status = 'ready', thumb_key = ?, preview_key = ?, width = ?, height = ?, taken_at = ?,
last_error = NULL, updated_at = ?
WHERE id = ?`),
thumbKey, previewKey, width, height, takenArg, db.TimeArg(time.Now()), id)
return err
}
func (db *DB) MarkPhotoFailed(ctx context.Context, id string, attempts int, cause error) error {
backoff := time.Duration(1<<min(attempts, 6)) * time.Minute
msg := fmt.Sprintf("%v", cause)
if len(msg) > 1000 {
msg = msg[:1000]
}
_, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'failed', last_error = ?, next_attempt_at = ?, updated_at = ?
WHERE id = ?`),
msg, db.TimeArg(time.Now().Add(backoff)), db.TimeArg(time.Now()), id)
return err
}
// RequeuePhoto resets a failed photo for a fresh round of attempts.
func (db *DB) RequeuePhoto(ctx context.Context, id string) error {
res, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'queued', attempts = 0, next_attempt_at = ?, updated_at = ?
WHERE id = ? AND status IN ('failed','queued')`),
db.TimeArg(time.Now()), db.TimeArg(time.Now()), id)
if err != nil {
return err
}
return errIfNoRows(res)
}
// RecoverStuckProcessing requeues photos left in 'processing' by a crash.
func (db *DB) RecoverStuckProcessing(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'queued', updated_at = ?
WHERE status = 'processing' AND updated_at < ?`),
db.TimeArg(time.Now()), db.TimeArg(time.Now().Add(-olderThan)))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// GalleryCoverKeys returns thumb keys for cover selection: the explicit
// cover photo's thumb when set and ready, else the first ready photo's.
func (db *DB) GalleryCoverKey(ctx context.Context, g Gallery) (photoID, thumbKey string) {
if g.CoverPhotoID != "" {
if p, err := db.GetPhoto(ctx, g.CoverPhotoID); err == nil && p.Status == "ready" && p.GalleryID == g.ID {
return p.ID, p.ThumbKey
}
}
row := db.QueryRowContext(ctx, db.Rebind(`
SELECT id, thumb_key FROM photos_photos
WHERE gallery_id = ? AND status = 'ready'
ORDER BY position, created_at LIMIT 1`), g.ID)
var idRaw, keyRaw any
if err := row.Scan(&idRaw, &keyRaw); err != nil {
return "", ""
}
return asString(idRaw), asString(keyRaw)
}