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>
290 lines
7.4 KiB
Go
290 lines
7.4 KiB
Go
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
|
|
}
|
|
}
|