Files
Spanglish/photo-api/internal/store/galleries.go
T
MichilisandCursor c9a600b6d6 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>
2026-07-25 17:32:23 +00:00

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
}
}