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:
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user