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