Files
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

38 KiB
Raw Permalink Blame History

Photo API — Implementation Plan

Event photo galleries for the Spanglish platform, built as a standalone Go service in photo-api/. Written 2026-07-24 after a read-only investigation of the repo.

Revised 2026-07-24 after review. Decisions from the review that amend the sections below:

  1. Both Postgres and SQLite are supported from day 1, mirroring the backend's DB_TYPE/DATABASE_URL convention. Consequence: the dedicated photos Postgres schema is dropped (SQLite has no schemas); instead every table carries a photos_ name prefix in both engines, which still cannot collide with any Drizzle-owned table name. Migrations are dual-dialect (*.pg.sql / *.sqlite.sql), SQLite driver is modernc.org/sqlite (pure Go, keeps the cgo-free static binary), and queue claiming uses FOR UPDATE SKIP LOCKED on Postgres and a plain claim-UPDATE on SQLite.
  2. Deployment stays systemd-first with the Dockerfile as the secondary path (confirmed).
  3. Free events: any confirmed/checked_in ticket grants access when the event's price = 0; paid events still require a payments.status='paid' row (§7 SQL updated).
  4. Admin nav keeps the name "Photos" alongside the existing "Gallery" item (confirmed).
  5. HEIC is supported, not rejected: uploads sniffed as HEIC are converted to JPEG for the variant pipeline by shelling out to a host-installed converter (vips from libvips-tools, falling back to heif-convert); the Go binary stays cgo-free and the original .heic is stored byte-identical for download. If no converter is installed, HEIC uploads fail with a clear 415 and a startup warning is logged.
  6. Photo admin surface is admin/organizer only (confirmed).
  7. Local disk and S3 both supported from day 1 (was already planned).
  8. The service has its own photo-api/.env (was already planned).

0. Investigation summary (what the codebase actually is)

Database. Drizzle schema lives in one file, backend/src/db/schema.ts, with dual SQLite/Postgres table definitions selected at runtime by DB_TYPE (bottom-of-file conditional exports, lines ~716738). drizzle.config.ts + npm run db:generate exist, but the migration that actually runs is the hand-written idempotent script backend/src/db/migrate.ts (npm run db:migrate), which is not run automatically at server start. The local backend/.env sets DB_TYPE=postgres with DATABASE_URL=postgresql://spanglish:...@localhost:5432/spanglish_db (driver: pg via drizzle-orm/node-postgres, everything in the public schema). Relevant tables:

  • events: id (uuid in PG), slug, bilingual title/description fields, start_datetime, status (draft|published|unlisted|cancelled|completed|archived), banner_url, …
  • tickets: id, booking_id, user_id, event_id, attendee fields, status (pending|confirmed|cancelled|checked_in|on_hold), is_guest, …
  • payments: one row per ticket (ticket_id FK), status (pending|pending_approval|paid|refunded|failed|cancelled|on_hold), provider (bancard|lightning|cash|bank_transfer|tpago), paid_at.

A ticket is "paid" when its payments row has status='paid' (set in backend/src/routes/payments.ts on admin approval/PATCH, and in backend/src/routes/lnbits.ts for Lightning), at which point the ticket itself is set to confirmed. "Paid" lives on the payment, not the ticket.

Auth. Stateless JWT, library jose, HS256 with the shared symmetric secret JWT_SECRET, issuer spanglish, audience spanglish-app, 1-day expiry (backend/src/lib/auth.ts, createToken lines 236246). Payload: { sub: userId, email, role, tokenVersion, iat, exp }. Transport is Authorization: Bearer <token>; the frontend stores it in localStorage under spanglish-token (frontend/src/lib/api/client.ts). Roles live in a single users.role column: admin|organizer|staff|marketing|user; enforcement is requireAuth(roles?) (auth.ts:332), and media/admin routes use ['admin','organizer']. Revocation is DB-backed: users.token_version must match the token's tokenVersion claim, and account_status must be active. A Go service can validate tokens independently with JWT_SECRET + HS256 + iss/aud checks; honoring revocation additionally needs one indexed read of users. (There is a user_sessions table but it is a session list, not the auth mechanism.)

Backend conventions. Hono on Node (@hono/node-server), entry backend/src/index.ts. One Hono sub-router per domain in src/routes/*, mounted under /api/<domain>; shared helpers in src/lib/*; no controller/service layering. Validation: zod via @hono/zod-validator. Config: dotenv/config + ad-hoc process.env.* with inline defaults, no env schema. Responses: success is the resource keyed by name ({ media, url }, { contacts: [...] }) or { message }; errors are { error: string } with the status as the second arg; central app.onError{ error: 'Internal Server Error' } and app.notFound{ error: 'Not Found' }.

Existing media code (do not touch). backend/src/routes/media.ts (mounted at /api/media): POST /api/media/upload (auth ['admin','organizer'], Hono parseBody, magic-byte sniffing, UUID filename), plus GET/DELETE/list. Storage abstraction backend/src/lib/storage.ts: local ./uploads flat dir (served at /uploads/* from index.ts:1877) or S3 when S3_ENDPOINT + S3_BUCKET are set (S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_PUBLIC_URL, S3_FORCE_PATH_STYLE). One generic media table (id, file_url, type, related_id, related_type, created_at). No image resizing anywhere (no sharp). Note: despite the feature name, there is no gallery table or backend gallery feature — frontend/src/app/admin/gallery/page.tsx is an admin page that uploads via /api/media with relatedType: 'gallery'. Event flyers/banners are just events.banner_url strings pointing at /uploads/....

Frontend. Next.js 14.2 App Router (frontend/src/app), React 18, TS 5.5. Admin shell + nav: frontend/src/app/admin/layout.tsx with a navigationWithRoles array (Heroicons); protection = middleware.ts checking a non-authoritative spanglish-auth=1 cookie + client-side useAuth() guard; the API is the real gate. API calls: fetchApi wrapper in src/lib/api/client.ts (API_BASE = NEXT_PUBLIC_API_URL || '', Bearer from localStorage); per-domain modules in src/lib/api/*. Same-origin /api/* and /uploads/* are rewritten to BACKEND_URL in next.config.js. Data fetching: server components with revalidate for public SEO pages handing off to *Client.tsx; plain useEffect in admin (no react-query). Tailwind 3.4 with custom theme (primary.yellow #FBB82B, brand.navy, Poppins, rounded-card 16px, shadow-card); custom UI primitives in src/components/ui/ (Button, Card, Input, Skeleton incl. ImageGridSkeleton); no shadcn. Custom i18n: src/i18n/ (en/es JSON) + LanguageContext with t(). No public gallery page exists today; there is a homepage carousel fed from public/images/carrousel/.

Infra. Real deployment is bare-metal systemd + host nginx, not Docker: deploy/spanglish-backend.service (node dist/index.js, port 3018, EnvironmentFile=backend/.env, hardened with ProtectSystem=strict + ReadWritePaths for uploads/data) and deploy/spanglish-frontend.service (next start -p 3019). Host nginx (deploy/front-end_nginx.conf, back-end_nginx.conf, spanglish_upstreams.conf) terminates TLS (Let's Encrypt) and routes /api and /uploads → backend :3018, everything else → frontend :3019; client_max_body_size 20m. deploy/docker-compose.scale.yml is a documented-as-non-turnkey alternative that references a backend/Dockerfile which does not exist. There is no CI at all (no .github/). Secrets come from gitignored per-service .env files referenced by EnvironmentFile=.

Where your description and the repo disagree (flagged, not worked around)

  1. "Shared Postgres" — the backend is dual-engine and defaults to SQLite. The local backend/.env says DB_TYPE=postgres, so this plan assumes production is Postgres, but I cannot verify the production host's env from the repo. If production actually runs SQLite, the DB and ticket-check parts of this plan change materially (see Risks).
  2. "Its own container" — nothing in this repo is deployed in a container; there are zero Dockerfiles and the compose file is explicitly a non-production starting point. The plan makes the systemd path primary (a static Go binary fits it perfectly) and includes a Dockerfile so the service can run as a container in the compose-scale setup.
  3. "backend/ already has gallery … logic" — there is no gallery model in the backend; only the generic media table and an admin frontend page. The collision surface is smaller than described: it's the /api/media routes, the /uploads path/dir, and the S3_*/MEDIA_MAX_UPLOAD_MB env names. This plan avoids all of them.
  4. Ticket "paid" status lives on the payments table, not the ticket — the check must join both.

1. Architecture

A single Go 1.26 binary (photo-api) exposing HTTP under the path prefix /api/photos, sitting behind the same host nginx, sharing the existing Postgres database but owning its own schema (photos), and validating the existing HS256 JWTs itself.

Why this shape:

  • Same-origin path prefix instead of a new subdomain. The frontend already reaches the backend via same-origin /api/* (nginx in prod, next.config.js rewrites in dev). Adding one more-specific nginx location /api/photos/ and one more-specific Next rewrite keeps fetchApi working unchanged — no CORS, no new cert, no new env in the browser. Backend mounts (index.ts:1901-1916) include no /api/photos route, so there is no collision.
  • Shared DB, own schema. The ticket-visibility feature requires reading tickets/payments/users; that data lives in spanglish_db. Putting photo tables in a photos schema in the same database gives one-query access checks, one backup story, and zero drizzle-kit interaction (drizzle introspects/manages only the tables it defines, all in public; it never touches other schemas).
  • Systemd-first deployment, mirroring deploy/spanglish-backend.service: a new spanglish-photos.service running a static binary on port 3020 (prod; 3002 in dev, following the 3018/3019 prod vs 3001/3000 dev split).
  • Stdlib net/http with Go 1.22+ method/pattern routing (mux.HandleFunc("GET /api/photos/...")). The backend's Hono style — one router per domain, per-route middleware, { error } JSON — maps cleanly onto this without importing a framework. Boring, zero-dep, matches "small service".
  • Same response conventions as Hono backend: resource-keyed success bodies ({ gallery }, { galleries }, { photos }), { error: "..." } failures, Authorization: Bearer auth, roles ['admin','organizer'] for admin surface — so the frontend treats it exactly like another backend domain module.

Request path: browser → nginx (location ^~ /api/photos/) → photo-api :3020 → Postgres (photos.* for its data, read-only use of public.users/tickets/payments/events for auth + access checks) → local disk or S3 for bytes.

2. Database: schema and migrations

Owned by the Go service. drizzle-kit never sees it. All photo tables carry a photos_ name prefix (photos_galleries, photos_photos, photos_schema_migrations) in the same database the backend uses — Postgres or SQLite per DB_TYPE — created and migrated by embedded, versioned, dual-dialect SQL files (migrations/NNNN_*.pg.sql + NNNN_*.sqlite.sql) applied by a ~60-line runner inside the binary (photo-api migrate subcommand). This matches the repo's existing convention — the backend also ignores drizzle-kit for application and runs a hand-written migration script manually at deploy time. The runner:

  • on Postgres takes pg_advisory_lock on a fixed key (safe if two instances start); SQLite's file lock covers the single-host case,
  • creates photos_schema_migrations (version int primary key, applied_at),
  • applies each migration in a transaction, recording versions.

It is invoked by systemd as ExecStartPre=/…/photo-api migrate so deploys stay one-step, while Drizzle's tables are never touched. On SQLite the service opens the same database file as the backend (WAL is already enabled by the backend; photo-api sets busy_timeout and keeps write transactions short).

migrations/0001_init.pg.sql (the .sqlite.sql twin mirrors the backend's SQLite conventions: text ids generated in Go, text RFC3339 timestamps, integer booleans):

CREATE TABLE photos_galleries (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  slug            varchar(160) NOT NULL UNIQUE,
  title           varchar(200) NOT NULL,
  title_es        varchar(200),
  description     text,
  description_es  text,
  event_id        uuid,                          -- references public.events(id); soft reference, no FK (see note)
  visibility      varchar(10) NOT NULL DEFAULT 'private'
                  CHECK (visibility IN ('public','private','link','ticket')),
  share_token     varchar(64) NOT NULL UNIQUE,   -- 32 random bytes, base64url; used only for 'link' mode
  cover_photo_id  uuid,                          -- set after photos exist; soft reference to photos_photos
  created_by      uuid,                          -- public.users(id); soft reference
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX photos_galleries_event_idx      ON photos_galleries (event_id);
CREATE INDEX photos_galleries_visibility_idx ON photos_galleries (visibility);

CREATE TABLE photos_photos (
  id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  gallery_id        uuid NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE,
  position          integer NOT NULL DEFAULT 0,
  original_key      text NOT NULL,               -- storage key of the untouched original
  original_filename text,
  content_type      varchar(50) NOT NULL,
  size_bytes        bigint NOT NULL,
  width             integer,
  height            integer,
  thumb_key         text,                        -- set when variant is ready
  preview_key       text,
  taken_at          timestamptz,                 -- from EXIF when present
  status            varchar(12) NOT NULL DEFAULT 'queued'
                    CHECK (status IN ('queued','processing','ready','failed')),
  attempts          integer NOT NULL DEFAULT 0,
  next_attempt_at   timestamptz NOT NULL DEFAULT now(),
  last_error        text,
  created_at        timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position);
CREATE INDEX photos_photos_queue_idx       ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed');

No foreign keys into Drizzle-owned tables (events/users), deliberately: an FK from photos_* into a Drizzle-owned table would make future Drizzle migrations (drop/recreate, type changes) fail in confusing ways — exactly the "two tools fighting" scenario. Referential integrity for event_id is enforced in the handler (verify the event exists on create/update); a dangling event_id degrades gracefully (ticket-gated gallery just denies access; event card omitted).

The photo rows themselves are the processing queue (status/attempts/next_attempt_at) — no separate jobs table, no Redis. Queue claiming: FOR UPDATE SKIP LOCKED on Postgres, plain claim-UPDATE on SQLite (single host there anyway). Query text uses ? placeholders rebound to $n for Postgres by a small helper; ids are generated in Go (uuid), timestamps bound as Go time.Time (timestamptz on PG, RFC3339 text on SQLite, matching the backend's dual-schema style).

3. API surface

All routes under /api/photos. Auth = Authorization: Bearer <existing JWT>. "Admin" = role admin or organizer (mirrors backend/src/routes/media.ts). Errors are { error: string } with 400/401/403/404/413/500.

Admin (JWT, role admin|organizer)

Method & path Purpose Response
POST /api/photos/galleries Create gallery. Body: { title, titleEs?, description?, descriptionEs?, eventId?, visibility }. Slug generated from title (suffix on collision), share_token generated always. 201 { gallery }
GET /api/photos/galleries List all galleries (any visibility) with photo counts, cover thumb URL. Query: eventId?, limit/offset. { galleries: [...] }
GET /api/photos/galleries/:id Full gallery detail incl. photos in position order with per-variant URLs and processing status. { gallery, photos: [...] }
PATCH /api/photos/galleries/:id Update title/description/eventId/visibility/coverPhotoId. { gallery }
DELETE /api/photos/galleries/:id Delete gallery + photos + stored objects. { message }
POST /api/photos/galleries/:id/photos Multipart upload, field files (repeatable). Sniffs magic bytes (JPEG/PNG/WebP/GIF/AVIF; explicit 415 { error: "HEIC is not supported, please upload JPEG" } for HEIC). Stores original, inserts row status='queued', appends at end position. 201 { photos: [...] }
PATCH /api/photos/galleries/:id/order Body { photoIds: [uuid,...] } — full ordering; positions rewritten in one transaction. { message }
DELETE /api/photos/photos/:photoId Delete one photo + its objects; compacts positions. { message }
POST /api/photos/galleries/:id/share-token Rotate share token (invalidate old links). { gallery }
POST /api/photos/photos/:photoId/retry Re-queue a failed photo. { photo }

The share link the admin copies is a frontend URL: https://spanglishcommunity.com/photos/<slug>?token=<share_token>.

Viewer (public / optional JWT)

Method & path Purpose Response
GET /api/photos/public/galleries Public index: visibility='public' only, with cover thumb + count + linked-event summary. { galleries: [...] }
GET /api/photos/public/galleries/:slug?token= Single gallery incl. ready photos in order. Access check per matrix (§7); optional Bearer honored. { gallery, photos: [...] } or 401/403/404
GET /api/photos/files/:photoId/:variant?token= The bytes. `variant ∈ thumb preview
GET /api/photos/health Liveness (also mapped at /health on the port for systemd checks). { status: "ok" }

Photo objects in responses carry ready-to-use URLs (/api/photos/files/<id>/thumb etc., with ?token= appended by the client when in link mode) so the frontend never constructs storage paths.

4. photo-api/ layout and module setup

photo-api/
  go.mod                       # module github.com/Michilis/spanglish/photo-api
  .env.example
  Dockerfile                   # secondary path; multi-stage, static binary, distroless
  cmd/photo-api/main.go        # flag/subcommand: serve (default) | migrate
  migrations/                  # embedded SQL (embed.FS)
    0001_init.sql
  internal/config/config.go    # env parsing with defaults, mirrors backend's ad-hoc style but centralized
  internal/auth/auth.go        # JWT verify (HS256, iss/aud), tokenVersion+account_status check, role helpers
  internal/store/              # pgx queries: galleries.go, photos.go, access.go (ticket check), migrate.go
  internal/storage/            # storage.go (interface), local.go, s3.go
  internal/imaging/            # decode, EXIF orient, resize, encode JPEG
  internal/worker/worker.go    # variant-processing loop
  internal/httpapi/            # router.go, middleware.go, galleries.go, photos_upload.go, files.go, public.go, respond.go

Plain go.mod, no go.work. There is exactly one Go module and no cross-module Go imports, so a workspace file adds nothing. Dependencies (each justified, all boring):

  • github.com/jackc/pgx/v5 (via its stdlib adapter) — canonical Postgres driver; advisory locks.
  • modernc.org/sqlite — pure-Go SQLite driver (no cgo), for DB_TYPE=sqlite parity with the backend.
  • github.com/golang-jwt/jwt/v5 — HS256 verification of the existing tokens.
  • github.com/aws/aws-sdk-go-v2 (s3 + presign) — same SDK family the backend uses for S3; needed for the S3 storage backend and presigned URLs.
  • golang.org/x/image — high-quality scaling (draw.CatmullRom).
  • github.com/rwcarlsen/goexif — EXIF orientation + taken_at. (Tiny, stable, read-only.)

Monorepo integration. npm workspaces stay ["backend","frontend"] — a Go dir cannot be a workspace and nothing should pretend it is. Root package.json gets convenience scripts only:

"dev:photos":   "cd photo-api && go run ./cmd/photo-api",
"build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api",
"test:photos":  "cd photo-api && go test ./..."

and root dev adds the third concurrently entry. .gitignore additions: photo-api/bin/, photo-api/data/, (.env already covered). There is no CI in this repo, so nothing to wire; if CI is added later, path-filter Go steps on photo-api/**. Note: pnpm-lock.yaml sits untracked next to the committed package-lock.json — unrelated to this feature but worth resolving; scripts above are package-manager-agnostic.

5. Storage abstraction

type Storage interface {
    Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
    Open(ctx context.Context, key string) (io.ReadCloser, int64, error) // local streaming
    Delete(ctx context.Context, key string) error
    // PresignGet returns ("", ErrNoPresign) on local storage; handler then streams via Open.
    PresignGet(ctx context.Context, key, downloadFilename string, expiry time.Duration) (string, error)
}

Key layout (both backends): galleries/<galleryID>/orig/<photoID>.<ext>, .../thumb/<photoID>.jpg, .../preview/<photoID>.jpg.

  • local.go — root dir from STORAGE_PATH (default ./data/photos), 0600 files, atomic write via temp+rename. Not backend/uploads — completely disjoint tree, and nothing is nginx-static-served: all reads go through the access-checked /api/photos/files/ handler.
  • s3.go — same selection convention as backend/src/lib/storage.ts: S3 active when S3_ENDPOINT and S3_BUCKET are both set; honors S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE (default true, Garage/MinIO-friendly). Serving uses presigned GETs so private galleries stay private even on S3 — the bucket is never public.

Env var names deliberately reuse the backend's vocabulary (S3_*, DATABASE_URL, JWT_SECRET, PORT) because each systemd service loads its own EnvironmentFile (photo-api/.env) — same conventions, zero collision. The one shared-by-value var is JWT_SECRET (must equal the backend's) and DATABASE_URL (same DB). Recommend a different S3_BUCKET (e.g. spanglish-photos) than any backend bucket. Full key list in §9.

6. Image processing

Library: pure Go for the pipeline, external CLI only for HEIC decode — stdlib image/jpeg|png|gif, golang.org/x/image (webp decode, CatmullRom scaler), goexif. Reasoning: the real deployment is a hardened systemd unit on a bare host; govips/bimg would add a libvips system dependency, cgo, and non-static binaries, complicating both the host install and the (secondary) container image for a service whose write volume is "admin uploads a batch after an event", not a hot path. Pure Go costs ~13 s/photo per variant on large JPEGs — fine for an async queue.

  • HEIC (required per review): decoded by shelling out, since no viable pure-Go HEVC decoder exists. The worker converts HEIC→JPEG (quality 95, temp file in the service's data dir) via the first available of: HEIC_CONVERTER (explicit path/command), vips copy (Debian package libvips-tools), heif-convert (package libheif-examples), then feeds the JPEG into the normal pure-Go pipeline. The Go binary itself stays cgo-free/static; the converter is a runtime host dependency (added to the systemd host setup and the Dockerfile — which therefore uses debian:bookworm-slim rather than distroless). Missing converter ⇒ HEIC uploads get a clear 415 and startup logs a warning. Original .heic is stored byte-identical and served for download with its real content type. The sniffer recognizes ftyp brands heic|heix|hevc|hevx|mif1|msf1.
  • AVIF: accepted at upload only if decodable; stdlib has no AVIF decoder, so in practice AVIF is rejected with an explicit message (can ride the same converter escape hatch later if needed).

Variants (originals stored byte-identical, never touched):

Variant Long edge Format Quality Purpose
thumb 512 px JPEG 78 grid
preview 2048 px JPEG 85 lightbox
original as uploaded download

Re-encoding variants strips EXIF (including GPS — a privacy win for published pages); orientation is applied to pixels first. width/height/taken_at recorded on the row from the original.

Queue/retry: the photos.photos row is the job. Worker = single goroutine pool (size WORKER_CONCURRENCY, default 2) polling with SELECT ... WHERE status IN ('queued','failed') AND attempts < 5 AND next_attempt_at <= now() ORDER BY created_at LIMIT n FOR UPDATE SKIP LOCKED, plus an in-process channel nudge after each upload so the poll interval (10 s) is only a fallback. On error: attempts+1, exponential next_attempt_at, last_error; after 5 attempts stays failed and the admin UI shows a retry button (/photos/:id/retry). FOR UPDATE SKIP LOCKED makes multi-instance safe for free. Crash recovery: on startup, rows stuck in processing older than 10 min are re-queued.

7. Access control matrix

Caller types: anon (no/invalid JWT), member (valid JWT, any role), ticket-holder (member with paid ticket for the gallery's linked event), link-holder (anyone presenting the gallery's share_token), admin (JWT role admin|organizer).

Visibility anon member ticket-holder link-holder admin
public view/download + manage
private 404 404 404 404 (token ignored) + manage
link 404 without token 404 without token 404 without token with valid token + manage
ticket 401 403 valid token also grants (link is a superset escape hatch; token exists on every gallery but is only honored for link and ticket modes) + manage
  • Listing: only public galleries ever appear in /api/photos/public/galleries. link/ticket/private are unlisted; non-public misses return 404, not 403, so gallery existence isn't probeable (exception: ticket mode returns 401/403 so the frontend can prompt login/purchase — the gallery's existence is intentionally discoverable via its event page).
  • Enforcement lives in one function, store.Authorize(ctx, gallery, caller, token), called in exactly two places: the gallery-view handler and the file handler — every byte served re-checks; there are no capability URLs except the explicit share token, and S3 presigned URLs are short-lived (15 min) so a leaked presign expires.
  • Ticket check (the ticket row + payments join, per how "paid" actually works here):
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)      -- review decision: free events need only a confirmed ticket
LIMIT 1;

Chosen over the alternative (forwarding the user's JWT to the backend's existing GET /api/dashboard/tickets, backend/src/routes/dashboard.ts:98) because: we are already in the same database for our own tables, it is one indexed query instead of an HTTP hop + JSON parse, it has no runtime dependency on the backend being up, and it needs zero backend changes. The cost is read-coupling to four public columns (tickets.user_id/event_id/status, payments.ticket_id/status) — mitigated by confining every public.* query to internal/store/access.go and a startup sanity check that those columns exist (fail loud, not wrong).

  • JWT verification in Go: HS256 + iss=spanglish + aud=spanglish-app, then one SELECT role, token_version, account_status FROM public.users WHERE id=$1 to enforce the same revocation semantics as getAuthUser (backend/src/lib/auth.ts:320-327). Skipping the DB check would silently weaken logout-everywhere/suspension for photo routes; since we're in the DB anyway, we match the backend exactly.

8. Frontend work

New files (all following existing patterns — fetchApi, useLanguage().t, Tailwind theme, ui/ primitives):

  • src/lib/api/photos.ts — API module (types + calls for every endpoint in §3), re-exported from src/lib/api/index.ts.
  • Adminsrc/app/admin/photos/page.tsx: gallery list (cards: cover thumb, title, visibility badge, event, count; create-gallery modal). src/app/admin/photos/[id]/page.tsx: single gallery manager — multi-file upload (input + drag-drop, per-file progress, status polling for processing), thumbnail grid with drag-to-reorder (HTML5 DnD, no new dep; persists via PATCH .../order), visibility selector, event linker (reuses events list API), copy-share-link button (navigator.clipboard, react-hot-toast), set-cover, delete, retry-failed. Modeled on admin/gallery/page.tsx + admin/events/[id]/ structurally.
  • Publicsrc/app/(public)/photos/page.tsx: server component (fetch public index, revalidate: 300, metadata) → PhotosIndexClient.tsx grid. src/app/(public)/photos/[slug]/page.tsx: server component fetching the gallery without token (public galleries get SSR/SEO; link/ticket galleries fall through) → GalleryClient.tsx which re-fetches client-side with ?token= from useSearchParams() and the Bearer token, and renders: masonry-ish thumb grid (ImageGridSkeleton while loading), lightbox (new shared src/components/Lightbox.tsx — full-screen fixed inset-0 bg-black/90, keyboard/swipe nav, preview variant, download button → original URL; pattern extracted from the inline modal in admin/gallery/page.tsx), download-original per photo, ticket-mode gates (401 → login redirect with ?redirect=, 403 → "this gallery is for attendees" message linking the event).
  • i18n: new photos.* and admin.nav.photos keys in src/i18n/locales/{en,es}.json.

Existing files changed (complete list):

File Change
src/app/admin/layout.tsx one entry in navigationWithRoles: { name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin','organizer'] } (CameraIcon, since PhotoIcon is taken by the existing Gallery item)
next.config.js prepend rewrite { source: '/api/photos/:path*', destination: PHOTO_API_URL + '/api/photos/:path*' } before the generic /api/:path* rule (rewrites are order-sensitive); PHOTO_API_URL default http://localhost:3003
src/lib/api/index.ts re-export photos module
src/i18n/locales/en.json, es.json new keys
src/app/sitemap.ts add /photos + public gallery slugs (nice-to-have, phase 7)

Naming note: the admin nav will then contain both "Gallery" (existing homepage-media page at /admin/gallery) and "Photos" (new). Requirement says "Photos"; flagged in Questions since admins may find the pair confusing.

9. Deployment and config

New files in deploy/ (mirroring existing ones; no existing file is replaced — the two nginx confs get additive location blocks):

  • deploy/spanglish-photos.service — copy of the backend unit shape: user spanglish, ExecStartPre=…/photo-api/bin/photo-api migrate, ExecStart=…/photo-api/bin/photo-api, EnvironmentFile=…/photo-api/.env, PORT=3020, hardening incl. ReadWritePaths=…/photo-api/data.
  • deploy/spanglish_upstreams.conf — add upstream spanglish_photos { server 127.0.0.1:3020; }.
  • deploy/front-end_nginx.conf — add, above location /api: location ^~ /api/photos/ { proxy_pass http://spanglish_photos; client_max_body_size 100m; proxy_request_buffering off; } (uploads are big; the vhost default is 20m). Same block in deploy/back-end_nginx.conf if the api subdomain should serve it too.
  • photo-api/Dockerfile — secondary/container path: golang:1.26 build stage (CGO_ENABLED=0), gcr.io/distroless/static runtime; and a commented photos service example for deploy/docker-compose.scale.yml (compose file itself untouched until that path is actually used).

photo-api/.env.example:

PORT=3003                          # dev; systemd unit overrides to 3020
DB_TYPE=postgres                   # postgres | sqlite — must match the backend's engine
DATABASE_URL=postgresql://...      # SAME database as backend (photos_* tables created by migrate);
                                   # for sqlite: path to the backend's db file, e.g. ../backend/data/spanglish.db
JWT_SECRET=                        # MUST equal backend/.env JWT_SECRET
HEIC_CONVERTER=                    # optional; default autodetects `vips`, then `heif-convert`
STORAGE_PATH=./data/photos         # local storage root (ignored when S3 is on)
S3_ENDPOINT=                       # + S3_BUCKET both set => S3 backend (same convention as backend)
S3_REGION=auto
S3_BUCKET=                         # use a photos-specific bucket
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=true
MAX_UPLOAD_MB=50                   # per file
WORKER_CONCURRENCY=2
FRONTEND_URL=https://spanglishcommunity.com   # for building share links

Frontend .env gains PHOTO_API_URL=http://localhost:3003 (dev; prod nginx routes directly so the rewrite is unused there, matching how /api works today).

10. Build sequence

  1. Scaffold + DB. go.mod, config, cmd/photo-api with serve/migrate, migration runner + 0001_init.sql, /health, root package.json scripts. Test: photo-api migrate creates photos.* against the dev DB; psql \dn shows the schema; re-run is a no-op; drizzle-kit generate still produces no diff.
  2. Auth + gallery CRUD. JWT middleware (incl. token_version check), all /galleries admin endpoints. Test: curl with a real token from the running backend login; non-admin gets 403; CRUD round-trips.
  3. Upload + local storage + worker. Multipart handler, sniffing, LocalStorage, imaging pipeline, queue/retry, files/:id/:variant for admins. Test: upload a batch of real photos incl. a portrait-orientation iPhone JPEG (EXIF rotation) and a deliberately corrupt file; variants appear; corrupt file lands in failed with last_error; retry works.
  4. Access control + public endpoints. Authorize, ticket-check query, public/galleries, public/galleries/:slug, token handling, presign-vs-stream in file handler. Test: matrix in §7 as a Go table test against seeded DB rows (paid/pending/refunded payments), plus curl for each caller type.
  5. S3 backend. S3Storage + presigned GETs, verified against a local MinIO/Garage. Test: same upload/download flows with S3_* set; private gallery file URL fails without presign.
  6. Admin frontend. /admin/photos pages, nav item, api module, i18n. Test: create → upload → reorder → visibility → copy link, in-browser against dev services.
  7. Public frontend. /photos, /photos/[slug], Lightbox, download, gated states, sitemap. Test: all four visibility modes in-browser, incl. link URL in an incognito window and ticket mode with a paid vs pending test user.
  8. Deploy. systemd unit, nginx blocks, .env.examples, Dockerfile, README section in photo-api/. Test: on the host — systemctl start spanglish-photos, nginx reload, end-to-end over HTTPS.

11. Risks and open questions

Risks / could-not-determine:

  • Production DB engine is unverifiable from the repo (local backend/.env says DB_TYPE=postgres; prod env is gitignored). Mitigated by the review decision to support both engines — whichever prod runs, photo-api points at the same database with matching DB_TYPE. On SQLite, two processes share one file: WAL is already on, photo-api adds busy_timeout and short write transactions, but write contention under heavy simultaneous booking + photo-processing load is a residual (small) risk.
  • Schema read-coupling. The ticket/user queries read five public columns Drizzle owns. A future rename breaks photo access checks at runtime; mitigated by the startup column sanity-check (fail fast) and by the queries living in one file.
  • Free events. If a PYG-0 event issues confirmed tickets whose payment row is not 'paid' (e.g. no payment or cash/pending), the paid-ticket join denies attendees. I could not confirm from the code what a free booking writes into payments. May need OR (e.price = 0 AND t.status IN ('confirmed','checked_in')).
  • Guest attendees (tickets.is_guest, tickets bought by someone else): access is keyed on tickets.user_id, so a +1 without their own account cannot pass ticket gating. Workaround exists (admin shares the link token), but it's a product gap to acknowledge.
  • HEIC depends on a host-installed converter (vips or heif-convert). If the prod host lacks the package, HEIC uploads 415 until it's installed; startup logs a warning so this is visible, and the deploy notes include the apt install libvips-tools step.
  • Nginx body size / timeouts for 100 MB batch uploads over spotty Paraguayan mobile connections; proxy_request_buffering off planned, but real-world testing is in phase 8.
  • 1-day JWT expiry with no working refresh flow (refresh tokens are minted but no endpoint consumes them) means long admin upload sessions can hit 401 mid-batch; the admin UI must surface that as "session expired, log in again" rather than a generic failure.

Questions — answered in review 2026-07-24 (see the revision note at the top): dual Postgres+SQLite support; systemd-first confirmed; free events count any confirmed ticket; nav name "Photos" kept; HEIC supported via converter shell-out; admin/organizer only; local disk and S3 both supported from day 1; the service keeps its own .env. The production engine check (and, if Postgres, connectivity for the spanglish role) is still on the user's side.