From c9a600b6d6e4739e30be8c0640ecfdd0eeb0a2a2 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sat, 25 Jul 2026 17:32:23 +0000 Subject: [PATCH 1/4] 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 --- .gitignore | 5 + deploy/back-end_nginx.conf | 23 + deploy/front-end_nginx.conf | 18 + deploy/spanglish-photos.service | 30 + deploy/spanglish_upstreams.conf | 4 + frontend/.env.example | 5 + frontend/next.config.js | 11 + .../app/(public)/events/[id]/gallery/page.tsx | 50 ++ .../app/(public)/photos/PhotosIndexClient.tsx | 72 ++ .../(public)/photos/[slug]/GalleryClient.tsx | 303 +++++++ .../src/app/(public)/photos/[slug]/page.tsx | 51 ++ frontend/src/app/(public)/photos/page.tsx | 30 + frontend/src/app/admin/layout.tsx | 2 + .../src/app/admin/photos/VisibilityBadge.tsx | 32 + frontend/src/app/admin/photos/[id]/page.tsx | 617 ++++++++++++++ frontend/src/app/admin/photos/page.tsx | 221 +++++ frontend/src/app/sitemap.ts | 41 +- frontend/src/components/Lightbox.tsx | 145 ++++ frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/es.json | 1 + frontend/src/lib/api/index.ts | 8 + frontend/src/lib/api/photos.ts | 176 ++++ frontend/tsconfig.json | 22 +- package.json | 6 +- photo-api/.env.example | 43 + photo-api/Dockerfile | 25 + photo-api/Makefile | 23 + photo-api/PLAN.md | 334 ++++++++ photo-api/README.md | 92 +++ photo-api/cmd/photo-api/main.go | 106 +++ photo-api/go.mod | 46 ++ photo-api/go.sum | 117 +++ photo-api/internal/auth/auth.go | 99 +++ photo-api/internal/config/config.go | 126 +++ photo-api/internal/httpapi/access.go | 55 ++ photo-api/internal/httpapi/api_test.go | 504 ++++++++++++ photo-api/internal/httpapi/dto.go | 153 ++++ photo-api/internal/httpapi/files.go | 117 +++ photo-api/internal/httpapi/galleries.go | 225 ++++++ photo-api/internal/httpapi/middleware.go | 36 + photo-api/internal/httpapi/photos.go | 242 ++++++ photo-api/internal/httpapi/public.go | 79 ++ photo-api/internal/httpapi/respond.go | 39 + photo-api/internal/httpapi/server.go | 90 +++ photo-api/internal/httpapi/slug.go | 72 ++ photo-api/internal/imaging/heic.go | 60 ++ photo-api/internal/imaging/imaging.go | 159 ++++ photo-api/internal/imaging/sniff.go | 30 + photo-api/internal/storage/local.go | 91 +++ photo-api/internal/storage/s3.go | 92 +++ photo-api/internal/storage/storage.go | 32 + photo-api/internal/store/access.go | 113 +++ photo-api/internal/store/db.go | 122 +++ photo-api/internal/store/galleries.go | 289 +++++++ photo-api/internal/store/migrate.go | 115 +++ photo-api/internal/store/photos.go | 271 +++++++ photo-api/internal/worker/worker.go | 185 +++++ photo-api/migrations/0001_init.pg.sql | 46 ++ photo-api/migrations/0001_init.sqlite.sql | 46 ++ photo-api/migrations/embed.go | 8 + pnpm-lock.yaml | 763 ++++++++++++++++++ 61 files changed, 6912 insertions(+), 7 deletions(-) create mode 100644 deploy/spanglish-photos.service create mode 100644 frontend/src/app/(public)/events/[id]/gallery/page.tsx create mode 100644 frontend/src/app/(public)/photos/PhotosIndexClient.tsx create mode 100644 frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx create mode 100644 frontend/src/app/(public)/photos/[slug]/page.tsx create mode 100644 frontend/src/app/(public)/photos/page.tsx create mode 100644 frontend/src/app/admin/photos/VisibilityBadge.tsx create mode 100644 frontend/src/app/admin/photos/[id]/page.tsx create mode 100644 frontend/src/app/admin/photos/page.tsx create mode 100644 frontend/src/components/Lightbox.tsx create mode 100644 frontend/src/lib/api/photos.ts create mode 100644 photo-api/.env.example create mode 100644 photo-api/Dockerfile create mode 100644 photo-api/Makefile create mode 100644 photo-api/PLAN.md create mode 100644 photo-api/README.md create mode 100644 photo-api/cmd/photo-api/main.go create mode 100644 photo-api/go.mod create mode 100644 photo-api/go.sum create mode 100644 photo-api/internal/auth/auth.go create mode 100644 photo-api/internal/config/config.go create mode 100644 photo-api/internal/httpapi/access.go create mode 100644 photo-api/internal/httpapi/api_test.go create mode 100644 photo-api/internal/httpapi/dto.go create mode 100644 photo-api/internal/httpapi/files.go create mode 100644 photo-api/internal/httpapi/galleries.go create mode 100644 photo-api/internal/httpapi/middleware.go create mode 100644 photo-api/internal/httpapi/photos.go create mode 100644 photo-api/internal/httpapi/public.go create mode 100644 photo-api/internal/httpapi/respond.go create mode 100644 photo-api/internal/httpapi/server.go create mode 100644 photo-api/internal/httpapi/slug.go create mode 100644 photo-api/internal/imaging/heic.go create mode 100644 photo-api/internal/imaging/imaging.go create mode 100644 photo-api/internal/imaging/sniff.go create mode 100644 photo-api/internal/storage/local.go create mode 100644 photo-api/internal/storage/s3.go create mode 100644 photo-api/internal/storage/storage.go create mode 100644 photo-api/internal/store/access.go create mode 100644 photo-api/internal/store/db.go create mode 100644 photo-api/internal/store/galleries.go create mode 100644 photo-api/internal/store/migrate.go create mode 100644 photo-api/internal/store/photos.go create mode 100644 photo-api/internal/worker/worker.go create mode 100644 photo-api/migrations/0001_init.pg.sql create mode 100644 photo-api/migrations/0001_init.sqlite.sql create mode 100644 photo-api/migrations/embed.go create mode 100644 pnpm-lock.yaml diff --git a/.gitignore b/.gitignore index ffc5a40..26037d1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ package-lock.json # Build outputs dist/ .next/ +.next-build/ out/ build/ @@ -56,6 +57,10 @@ yarn-error.log* # Testing coverage/ +# Go photo-api service +photo-api/bin/ +photo-api/data/ + # Misc *.pem diff --git a/deploy/back-end_nginx.conf b/deploy/back-end_nginx.conf index 589202d..ff48e0b 100644 --- a/deploy/back-end_nginx.conf +++ b/deploy/back-end_nginx.conf @@ -63,6 +63,29 @@ server { return 413 '{"error":"Payload too large (413). Please upload a smaller file."}'; } + # Photo gallery service (photo-api, port 3020). ^~ wins over the / + # prefix below; bigger body cap and unbuffered uploads for photo batches. + location ^~ /api/photos/ { + limit_req zone=spanglish_api_limit burst=50 nodelay; + + proxy_pass http://spanglish_photos; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Strip CORS headers from the service (nginx handles CORS here) + proxy_hide_header 'Access-Control-Allow-Origin'; + proxy_hide_header 'Access-Control-Allow-Methods'; + + client_max_body_size 100m; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + } + location / { limit_req zone=spanglish_api_limit burst=50 nodelay; diff --git a/deploy/front-end_nginx.conf b/deploy/front-end_nginx.conf index 9b96a40..c6b6225 100644 --- a/deploy/front-end_nginx.conf +++ b/deploy/front-end_nginx.conf @@ -79,6 +79,24 @@ server { proxy_connect_timeout 300s; } + # Photo gallery service (photo-api, port 3020). ^~ wins over the /api + # prefix below. Batch photo uploads are large and slow, so the body cap + # is raised and request buffering is off for this location only. + location ^~ /api/photos/ { + proxy_pass http://spanglish_photos; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + client_max_body_size 100m; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + } + # Proxy /api to backend location /api { proxy_pass http://spanglish_backend; diff --git a/deploy/spanglish-photos.service b/deploy/spanglish-photos.service new file mode 100644 index 0000000..29de8cc --- /dev/null +++ b/deploy/spanglish-photos.service @@ -0,0 +1,30 @@ +[Unit] +Description=Spanglish Photo Gallery API +Documentation=https://git.azzamo.net/Michilis/Spanglish +After=network.target spanglish-backend.service + +[Service] +Type=simple +User=spanglish +Group=spanglish +WorkingDirectory=/home/spanglish/Spanglish/photo-api +EnvironmentFile=/home/spanglish/Spanglish/photo-api/.env +Environment=PORT=3020 +# Apply pending photos_* migrations before serving (idempotent, advisory-locked) +ExecStartPre=/home/spanglish/Spanglish/photo-api/bin/photo-api migrate +ExecStart=/home/spanglish/Spanglish/photo-api/bin/photo-api +Restart=on-failure +RestartSec=10 +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=spanglish-photos + +# Security hardening (mirrors spanglish-backend.service) +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/spanglish/Spanglish/photo-api/data + +[Install] +WantedBy=multi-user.target diff --git a/deploy/spanglish_upstreams.conf b/deploy/spanglish_upstreams.conf index 26b2462..f472d28 100644 --- a/deploy/spanglish_upstreams.conf +++ b/deploy/spanglish_upstreams.conf @@ -4,4 +4,8 @@ upstream spanglish_frontend { upstream spanglish_backend { server 127.0.0.1:3018; +} + +upstream spanglish_photos { + server 127.0.0.1:3020; } \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example index c7d9259..463c8ba 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -7,6 +7,11 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com # API URL (leave empty for same-origin proxy) NEXT_PUBLIC_API_URL= +# Photo gallery service (photo-api) origin, used by the /api/photos rewrite +# and server-side rendering of /photos pages. +# Dev default: http://localhost:3003 — production: http://127.0.0.1:3020 +PHOTO_API_URL= + # Google OAuth (optional - leave empty to hide Google Sign-In button) # Get your Client ID from: https://console.cloud.google.com/apis/credentials # 1. Create a new OAuth 2.0 Client ID (Web application) diff --git a/frontend/next.config.js b/frontend/next.config.js index f34c070..7f20796 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -4,6 +4,10 @@ // being hardcoded to localhost. const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; +// The standalone photo gallery service (photo-api/). Must be rewritten +// before the generic /api rule below — Next rewrites are order-sensitive. +const PHOTO_API_URL = process.env.PHOTO_API_URL || 'http://localhost:3003'; + // Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN). const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '') .split(',') @@ -20,6 +24,9 @@ const securityHeaders = [ ]; const nextConfig = { + // Overridable so a production build can run while `next dev` holds .next + // (e.g. NEXT_DIST_DIR=.next-build npm run build). + distDir: process.env.NEXT_DIST_DIR || '.next', images: { // Restrict remote image sources to a known allowlist instead of allowing any // https host (which let the Next image optimizer be used as an open proxy). @@ -39,6 +46,10 @@ const nextConfig = { }, async rewrites() { return [ + { + source: '/api/photos/:path*', + destination: `${PHOTO_API_URL}/api/photos/:path*`, + }, { source: '/api/:path*', destination: `${BACKEND_URL}/api/:path*`, diff --git a/frontend/src/app/(public)/events/[id]/gallery/page.tsx b/frontend/src/app/(public)/events/[id]/gallery/page.tsx new file mode 100644 index 0000000..1d4b005 --- /dev/null +++ b/frontend/src/app/(public)/events/[id]/gallery/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; +import type { PhotoGallery, Photo } from '@/lib/api'; +import GalleryClient from '@/app/(public)/photos/[slug]/GalleryClient'; + +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +interface PageProps { + params: { id: string }; // event slug, same param name as the parent route +} + +// Public event galleries render server-side; link/ticket galleries return +// null here and are fetched client-side with the viewer's token. +async function getEventGallery( + eventSlug: string +): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> { + try { + const res = await fetch( + `${photoApiUrl}/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery`, + { next: { revalidate: 300 } } + ); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +export async function generateMetadata({ params }: PageProps): Promise { + const data = await getEventGallery(params.id); + if (!data) { + return { title: 'Event Photos', robots: { index: false } }; + } + return { + title: `${data.gallery.title} – Photos`, + description: + data.gallery.description || + `Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`, + }; +} + +export default async function EventGalleryPage({ params }: PageProps) { + const initial = await getEventGallery(params.id); + return ( + // Suspense boundary required by useSearchParams() in the client child. + + + + ); +} diff --git a/frontend/src/app/(public)/photos/PhotosIndexClient.tsx b/frontend/src/app/(public)/photos/PhotosIndexClient.tsx new file mode 100644 index 0000000..da01cf7 --- /dev/null +++ b/frontend/src/app/(public)/photos/PhotosIndexClient.tsx @@ -0,0 +1,72 @@ +'use client'; + +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import type { PhotoGallery } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import { CameraIcon } from '@heroicons/react/24/outline'; + +export default function PhotosIndexClient({ galleries }: { galleries: PhotoGallery[] }) { + const { locale } = useLanguage(); + const es = locale === 'es'; + + const formatDate = (iso?: string) => { + if (!iso) return null; + return new Date(iso).toLocaleDateString(es ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'long', + timeZone: 'America/Asuncion', + }); + }; + + return ( +
+
+

+ {es ? 'Galerías de Fotos' : 'Photo Galleries'} +

+

+ {es + ? 'Recuerdos de nuestros intercambios de idiomas en Asunción' + : 'Memories from our language exchanges in Asunción'} +

+ + {galleries.length === 0 ? ( +
+ +

+ {es ? 'Aún no hay galerías publicadas.' : 'No galleries published yet.'} +

+
+ ) : ( +
+ {galleries.map((g) => ( + // Event-linked galleries live under the event's URL. + + +
+ {g.coverUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+
+

+ {es && g.titleEs ? g.titleEs : g.title} +

+

+ {g.photoCount} {es ? 'fotos' : 'photos'} + {g.event?.startDatetime && <> · {formatDate(g.event.startDatetime)}} +

+
+
+ + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx new file mode 100644 index 0000000..c75aa2c --- /dev/null +++ b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx @@ -0,0 +1,303 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useSearchParams, useRouter, usePathname } from 'next/navigation'; +import { useLanguage } from '@/context/LanguageContext'; +import { useAuth } from '@/context/AuthContext'; +import { photosApi, PhotoGallery, Photo } from '@/lib/api'; +import Button from '@/components/ui/Button'; +import { ImageGridSkeleton } from '@/components/ui/Skeleton'; +import Lightbox from '@/components/Lightbox'; +import { + ArrowDownTrayIcon, + CalendarIcon, + CameraIcon, + LockClosedIcon, + TicketIcon, +} from '@heroicons/react/24/outline'; + +interface GalleryClientProps { + /** Fetch by gallery slug (standalone galleries at /photos/[slug]) … */ + slug?: string; + /** … or by event slug (event galleries at /events/[slug]/gallery). */ + eventSlug?: string; + initial: { gallery: PhotoGallery; photos: Photo[] } | null; +} + +type DeniedState = 'login' | 'ticket' | 'notfound' | null; + +export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) { + const { locale } = useLanguage(); + const es = locale === 'es'; + const { user, isLoading: authLoading } = useAuth(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + const shareToken = searchParams.get('token') || undefined; + + const [gallery, setGallery] = useState(initial?.gallery ?? null); + const [photos, setPhotos] = useState(initial?.photos ?? []); + const [loading, setLoading] = useState(!initial); + const [denied, setDenied] = useState(null); + const [lightboxIndex, setLightboxIndex] = useState(null); + + // Server-rendered public galleries need no client fetch. Everything else + // (link/ticket/private) is fetched here with the share token and/or the + // viewer's own auth token attached. + useEffect(() => { + if (initial) return; + if (authLoading) return; // wait so the Bearer token is available + let cancelled = false; + setLoading(true); + const fetcher = eventSlug + ? photosApi.getEventGallery(eventSlug, shareToken) + : photosApi.getPublicGallery(slug || '', shareToken); + fetcher + .then((data) => { + if (cancelled) return; + setGallery(data.gallery); + setPhotos(data.photos); + setDenied(null); + }) + .catch((err: Error) => { + if (cancelled) return; + const msg = err.message || ''; + if (msg.includes('Authentication required')) setDenied('login'); + else if (msg.includes('attendees')) setDenied('ticket'); + else setDenied('notfound'); + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [slug, eventSlug, shareToken, initial, authLoading, user?.id]); + + if (loading || (authLoading && !initial)) { + return ( +
+
+ +
+
+ ); + } + + if (denied === 'login') { + const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`); + return ( + router.push(`/login?redirect=${redirect}`)}> + {es ? 'Iniciar sesión' : 'Log in'} + + } + /> + ); + } + + if (denied === 'ticket') { + return ( + + + + ) : undefined + } + /> + ); + } + + if (denied === 'notfound' || !gallery) { + return ( + + + + } + /> + ); + } + + const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb); + const lightboxItems = readyPhotos.map((p) => ({ + id: p.id, + previewUrl: p.urls.preview || p.urls.original, + downloadUrl: p.urls.original, + filename: p.originalFilename, + })); + + const title = es && gallery.titleEs ? gallery.titleEs : gallery.title; + const description = es && gallery.descriptionEs ? gallery.descriptionEs : gallery.description; + const eventTitle = gallery.event + ? es && gallery.event.titleEs + ? gallery.event.titleEs + : gallery.event.title + : null; + const eventDate = gallery.event?.startDatetime + ? new Date(gallery.event.startDatetime).toLocaleDateString(es ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'America/Asuncion', + }) + : null; + + // Hero backdrop: the cover photo's preview when available, else the + // first photo. Falls back to a navy gradient with no image. + const coverPhoto = + readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null; + const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null; + + return ( +
+ {/* Hero */} +
+ {heroUrl && ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + )} +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ + + {readyPhotos.length} {es ? 'fotos' : 'photos'} + + {gallery.event && ( + + + {eventTitle} + {eventDate && · {eventDate}} + + )} +
+
+
+ + {/* Masonry grid */} +
+ {readyPhotos.length === 0 ? ( +
+ +

+ {es ? 'Las fotos estarán disponibles pronto.' : 'Photos will be available soon.'} +

+
+ ) : ( +
+ {readyPhotos.map((photo, i) => ( +
setLightboxIndex(i)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setLightboxIndex(i); + } + }} + className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow" + style={ + photo.width && photo.height + ? { aspectRatio: `${photo.width} / ${photo.height}` } + : undefined + } + > + {/* eslint-disable-next-line @next/next/no-img-element */} + + + ))} +
+ )} + + {lightboxIndex !== null && ( + setLightboxIndex(null)} + onNavigate={setLightboxIndex} + /> + )} +
+
+ ); +} + +function GateMessage({ + icon: Icon, + title, + body, + action, +}: { + icon: typeof CameraIcon; + title: string; + body: string; + action?: React.ReactNode; +}) { + return ( +
+
+ +

{title}

+

{body}

+ {action} +
+
+ ); +} diff --git a/frontend/src/app/(public)/photos/[slug]/page.tsx b/frontend/src/app/(public)/photos/[slug]/page.tsx new file mode 100644 index 0000000..736af70 --- /dev/null +++ b/frontend/src/app/(public)/photos/[slug]/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; +import type { PhotoGallery, Photo } from '@/lib/api'; +import GalleryClient from './GalleryClient'; + +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +interface PageProps { + params: { slug: string }; +} + +// Public galleries render server-side (SEO + fast first paint); link and +// ticket galleries return null here and are fetched client-side with the +// viewer's token / share token. +async function getPublicGallery( + slug: string +): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> { + try { + const res = await fetch( + `${photoApiUrl}/api/photos/public/galleries/${encodeURIComponent(slug)}`, + { next: { revalidate: 300 } } + ); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +export async function generateMetadata({ params }: PageProps): Promise { + const data = await getPublicGallery(params.slug); + if (!data) { + return { title: 'Photo Gallery', robots: { index: false } }; + } + return { + title: `${data.gallery.title} – Photos`, + description: + data.gallery.description || + `Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`, + }; +} + +export default async function GalleryPage({ params }: PageProps) { + const initial = await getPublicGallery(params.slug); + return ( + // Suspense boundary required by useSearchParams() in the client child. + + + + ); +} diff --git a/frontend/src/app/(public)/photos/page.tsx b/frontend/src/app/(public)/photos/page.tsx new file mode 100644 index 0000000..62f774f --- /dev/null +++ b/frontend/src/app/(public)/photos/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from 'next'; +import type { PhotoGallery } from '@/lib/api'; +import PhotosIndexClient from './PhotosIndexClient'; + +// Server-side calls go straight to the photo-api service; the browser uses +// the same-origin /api/photos path via the Next rewrite / nginx. +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +export const metadata: Metadata = { + title: 'Event Photo Galleries', + description: 'Photos from past Spanglish language exchange events in Asunción.', +}; + +async function getGalleries(): Promise { + try { + const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, { + next: { revalidate: 300 }, + }); + if (!res.ok) return []; + const data = await res.json(); + return data.galleries || []; + } catch { + return []; + } +} + +export default async function PhotosPage() { + const galleries = await getGalleries(); + return ; +} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index d4cdced..16a27ec 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -17,6 +17,7 @@ import { EnvelopeIcon, InboxIcon, PhotoIcon, + CameraIcon, Cog6ToothIcon, ArrowLeftOnRectangleIcon, Bars3Icon, @@ -54,6 +55,7 @@ export default function AdminLayout({ { name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] }, { name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] }, { name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] }, + { name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin', 'organizer'] }, { name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] }, { name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] }, { name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] }, diff --git a/frontend/src/app/admin/photos/VisibilityBadge.tsx b/frontend/src/app/admin/photos/VisibilityBadge.tsx new file mode 100644 index 0000000..258c8b9 --- /dev/null +++ b/frontend/src/app/admin/photos/VisibilityBadge.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useLanguage } from '@/context/LanguageContext'; +import type { GalleryVisibility } from '@/lib/api'; +import { + GlobeAltIcon, + LockClosedIcon, + LinkIcon, + TicketIcon, +} from '@heroicons/react/24/outline'; + +const visibilityMeta: Record< + GalleryVisibility, + { icon: typeof GlobeAltIcon; className: string; en: string; es: string } +> = { + public: { icon: GlobeAltIcon, className: 'bg-green-100 text-green-700', en: 'Public', es: 'Pública' }, + private: { icon: LockClosedIcon, className: 'bg-gray-200 text-gray-700', en: 'Private', es: 'Privada' }, + link: { icon: LinkIcon, className: 'bg-blue-100 text-blue-700', en: 'Link only', es: 'Solo enlace' }, + ticket: { icon: TicketIcon, className: 'bg-yellow-100 text-yellow-800', en: 'Ticket holders', es: 'Con entrada' }, +}; + +export default function VisibilityBadge({ visibility }: { visibility: GalleryVisibility }) { + const { locale } = useLanguage(); + const meta = visibilityMeta[visibility] || visibilityMeta.private; + const Icon = meta.icon; + return ( + + + {locale === 'es' ? meta.es : meta.en} + + ); +} diff --git a/frontend/src/app/admin/photos/[id]/page.tsx b/frontend/src/app/admin/photos/[id]/page.tsx new file mode 100644 index 0000000..6b6808c --- /dev/null +++ b/frontend/src/app/admin/photos/[id]/page.tsx @@ -0,0 +1,617 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import { photosApi, eventsApi, PhotoGallery, Photo, Event, GalleryVisibility } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton'; +import Lightbox from '@/components/Lightbox'; +import VisibilityBadge from '../VisibilityBadge'; +import { + ArrowLeftIcon, + ArrowUpTrayIcon, + ArrowPathIcon, + CheckIcon, + CheckCircleIcon, + ChevronDownIcon, + ChevronUpIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, + LinkIcon, + PhotoIcon, + StarIcon, + TrashIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'; +import toast from 'react-hot-toast'; + +interface UploadItem { + key: string; + name: string; + sizeBytes: number; + progress: number; // 0..1 while uploading + status: 'queued' | 'uploading' | 'processing' | 'error'; + error?: string; + photoId?: string; +} + +function formatBytes(n: number) { + if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`; + if (n >= 1 << 10) return `${Math.round(n / (1 << 10))} KB`; + return `${n} B`; +} + +export default function AdminGalleryDetailPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { locale } = useLanguage(); + const es = locale === 'es'; + + const [gallery, setGallery] = useState(null); + const [photos, setPhotos] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + const [dragId, setDragId] = useState(null); + const [lightboxIndex, setLightboxIndex] = useState(null); + const fileInputRef = useRef(null); + + // Uploader panel state (Google Drive style: one row per file). + const [uploads, setUploads] = useState([]); + const [panelCollapsed, setPanelCollapsed] = useState(false); + const uploadQueue = useRef<{ key: string; file: File }[]>([]); + const pumpRunning = useRef(false); + + const load = useCallback(async () => { + try { + const [detail, ev] = await Promise.all([photosApi.getGallery(id), eventsApi.getAll()]); + setGallery(detail.gallery); + setPhotos(detail.photos); + setEvents(ev.events); + } catch { + toast.error(es ? 'No se pudo cargar la galería' : 'Failed to load gallery'); + router.push('/admin/photos'); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + useEffect(() => { + load(); + }, [load]); + + // Poll while any photo is still processing so thumbnails (and the + // uploader panel rows) update as the worker finishes them. + const processingCount = photos.filter((p) => p.status === 'queued' || p.status === 'processing').length; + useEffect(() => { + if (processingCount === 0) return; + const timer = setInterval(async () => { + try { + const detail = await photosApi.getGallery(id); + setGallery(detail.gallery); + setPhotos(detail.photos); + } catch { + /* transient; next tick retries */ + } + }, 3000); + return () => clearInterval(timer); + }, [processingCount, id]); + + const patchUpload = (key: string, patch: Partial) => { + setUploads((prev) => prev.map((u) => (u.key === key ? { ...u, ...patch } : u))); + }; + + // Sequential upload pump: one file at a time, byte progress per file. + const pump = useCallback(async () => { + if (pumpRunning.current) return; + pumpRunning.current = true; + while (uploadQueue.current.length > 0) { + const { key, file } = uploadQueue.current.shift()!; + patchUpload(key, { status: 'uploading', progress: 0 }); + try { + const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) => + patchUpload(key, { progress: fraction }) + ); + setPhotos((prev) => [...prev, ...added]); + patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id }); + } catch (err) { + patchUpload(key, { + status: 'error', + error: err instanceof Error ? err.message : 'Upload failed', + }); + } + } + pumpRunning.current = false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const handleUpload = (files: FileList | File[]) => { + const list = Array.from(files); + if (list.length === 0) return; + const items: UploadItem[] = list.map((file) => ({ + key: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + name: file.name, + sizeBytes: file.size, + progress: 0, + status: 'queued', + })); + uploadQueue.current.push(...items.map((item, i) => ({ key: item.key, file: list[i] }))); + setUploads((prev) => [...prev, ...items]); + setPanelCollapsed(false); + pump(); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + // A row is "done" once its photo finished processing; the panel derives + // this from the photos list instead of tracking it separately. + const displayStatus = (u: UploadItem): { state: string; error?: string } => { + if (u.status === 'processing' && u.photoId) { + const photo = photos.find((p) => p.id === u.photoId); + if (photo?.status === 'ready') return { state: 'done' }; + if (photo?.status === 'failed') return { state: 'error', error: photo.lastError || 'Processing failed' }; + } + return { state: u.status, error: u.error }; + }; + + const activeUploads = uploads.filter((u) => { + const s = displayStatus(u).state; + return s === 'queued' || s === 'uploading' || s === 'processing'; + }).length; + + const saveField = async (patch: Parameters[1], okMsg?: string) => { + try { + const { gallery: updated } = await photosApi.updateGallery(id, patch); + setGallery(updated); + if (okMsg) toast.success(okMsg); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Update failed'); + } + }; + + const copyShareLink = async () => { + if (!gallery?.shareUrl) return; + try { + await navigator.clipboard.writeText(gallery.shareUrl); + setCopied(true); + toast.success(es ? 'Enlace copiado' : 'Share link copied'); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error(es ? 'No se pudo copiar' : 'Failed to copy'); + } + }; + + const rotateToken = async () => { + if (!confirm(es ? '¿Invalidar el enlace actual y generar uno nuevo?' : 'Invalidate the current link and generate a new one?')) + return; + try { + const { gallery: updated } = await photosApi.rotateShareToken(id); + setGallery(updated); + toast.success(es ? 'Nuevo enlace generado' : 'New share link generated'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed'); + } + }; + + const deletePhoto = async (photoId: string): Promise => { + if (!confirm(es ? '¿Eliminar esta foto?' : 'Delete this photo?')) return false; + try { + await photosApi.deletePhoto(photoId); + setPhotos((prev) => prev.filter((p) => p.id !== photoId)); + toast.success(es ? 'Foto eliminada' : 'Photo deleted'); + return true; + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete'); + return false; + } + }; + + const retryPhoto = async (photoId: string) => { + try { + const { photo } = await photosApi.retryPhoto(photoId); + setPhotos((prev) => prev.map((p) => (p.id === photoId ? photo : p))); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to retry'); + } + }; + + const deleteGallery = async () => { + if (!confirm(es ? '¿Eliminar toda la galería y sus fotos?' : 'Delete the whole gallery and its photos?')) return; + try { + await photosApi.deleteGallery(id); + toast.success(es ? 'Galería eliminada' : 'Gallery deleted'); + router.push('/admin/photos'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete'); + } + }; + + // HTML5 drag-and-drop reorder; persisted on drop. + const onDropReorder = async (targetId: string) => { + if (!dragId || dragId === targetId) return; + const ids = photos.map((p) => p.id); + const from = ids.indexOf(dragId); + const to = ids.indexOf(targetId); + if (from < 0 || to < 0) return; + ids.splice(to, 0, ids.splice(from, 1)[0]); + const reordered = ids + .map((pid) => photos.find((p) => p.id === pid)) + .filter((p): p is Photo => !!p); + setPhotos(reordered); + setDragId(null); + try { + await photosApi.reorderPhotos(id, ids); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save order'); + load(); + } + }; + + if (loading || !gallery) { + return ( +
+ + +
+ ); + } + + const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb); + const lightboxItems = readyPhotos.map((p) => ({ + id: p.id, + previewUrl: p.urls.preview || p.urls.original, + downloadUrl: p.urls.original, + filename: p.originalFilename, + })); + const openLightboxFor = (photoId: string) => { + const idx = readyPhotos.findIndex((p) => p.id === photoId); + if (idx >= 0) setLightboxIndex(idx); + }; + + return ( +
{ + if (e.dataTransfer.types.includes('Files')) e.preventDefault(); + }} + onDrop={(e) => { + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + handleUpload(e.dataTransfer.files); + } + }} + > +
+
+ + + +

+ {es && gallery.titleEs ? gallery.titleEs : gallery.title} +

+ +
+
+ + e.target.files && handleUpload(e.target.files)} + className="hidden" + /> +
+
+ + {/* Settings */} + +
+
+ + +
+
+ + + {gallery.visibility === 'ticket' && !gallery.eventId && ( +

+ {es + ? 'El modo "con entrada" necesita un evento vinculado' + : 'Ticket mode needs a linked event'} +

+ )} +
+
+ +
+ + + +
+ {gallery.event && ( +

+ {es + ? 'Publicada en la página del evento: ' + : 'Published on the event page: '} + /events/{gallery.event.slug}/gallery +

+ )} +
+
+
+ + {processingCount > 0 && ( +

+ {es + ? `Procesando ${processingCount} foto(s)…` + : `Processing ${processingCount} photo(s)…`} +

+ )} + + {/* Photo grid */} + {photos.length === 0 ? ( + + +

+ {es + ? 'Arrastra fotos aquí o usa el botón de subir' + : 'Drag photos here or use the upload button'} +

+
+ ) : ( +
+ {photos.map((photo) => ( + setDragId(photo.id)} + onDragOver={(e) => { + if (dragId) e.preventDefault(); + }} + onDrop={(e) => { + if (dragId) { + e.preventDefault(); + e.stopPropagation(); + onDropReorder(photo.id); + } + }} + className={`group relative overflow-hidden aspect-square cursor-grab ${ + dragId === photo.id ? 'opacity-50' : '' + }`} + > + {photo.status === 'ready' && photo.urls.thumb ? ( + // eslint-disable-next-line @next/next/no-img-element + openLightboxFor(photo.id)} + /> + ) : photo.status === 'failed' ? ( +
+ + {photo.lastError || 'Failed'} +
+ ) : ( +
+
+
+ )} + + {gallery.coverPhotoId === photo.id && ( + + )} + +
+ {photo.status === 'ready' && ( + + )} + {photo.status === 'failed' && ( + + )} + +
+ + ))} +
+ )} + + {/* Danger zone */} +
+ +
+ + {/* Lightbox with admin actions */} + {lightboxIndex !== null && lightboxItems.length > 0 && ( + setLightboxIndex(null)} + onNavigate={setLightboxIndex} + renderActions={(item) => ( + <> + + + + )} + /> + )} + + {/* Uploader panel (Google Drive style) */} + {uploads.length > 0 && ( +
+
+
+

+ {activeUploads > 0 + ? es + ? `Subiendo ${uploads.length - activeUploads}/${uploads.length}…` + : `Uploading ${uploads.length - activeUploads}/${uploads.length}…` + : es + ? `${uploads.length} subida(s) completada(s)` + : `${uploads.length} upload(s) complete`} +

+
+ + {activeUploads === 0 && ( + + )} +
+
+ {!panelCollapsed && ( +
    + {uploads.map((u) => { + const ds = displayStatus(u); + return ( +
  • +
    + +
    +

    + {u.name} +

    +

    + {ds.state === 'uploading' && `${Math.round(u.progress * 100)}% · ${formatBytes(u.sizeBytes)}`} + {ds.state === 'queued' && (es ? 'En cola' : 'Queued')} + {ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')} + {ds.state === 'done' && formatBytes(u.sizeBytes)} + {ds.state === 'error' && ( + + {ds.error} + + )} +

    +
    + + {ds.state === 'done' && } + {ds.state === 'error' && } + {ds.state === 'processing' && ( +
    + )} + {ds.state === 'uploading' && ( + {Math.round(u.progress * 100)}% + )} + +
    + {ds.state === 'uploading' && ( +
    +
    +
    + )} +
  • + ); + })} +
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/app/admin/photos/page.tsx b/frontend/src/app/admin/photos/page.tsx new file mode 100644 index 0000000..693cd65 --- /dev/null +++ b/frontend/src/app/admin/photos/page.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import { photosApi, eventsApi, PhotoGallery, Event, GalleryVisibility } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton'; +import VisibilityBadge from './VisibilityBadge'; +import { + CameraIcon, + PlusIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; + +export default function AdminPhotosPage() { + const { locale } = useLanguage(); + const [galleries, setGalleries] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [creating, setCreating] = useState(false); + const [form, setForm] = useState({ + title: '', + titleEs: '', + eventId: '', + visibility: 'private' as GalleryVisibility, + }); + + useEffect(() => { + Promise.all([photosApi.getGalleries(), eventsApi.getAll()]) + .then(([g, e]) => { + setGalleries(g.galleries); + setEvents(e.events); + }) + .catch(() => toast.error(locale === 'es' ? 'Error al cargar galerías' : 'Failed to load galleries')) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.title.trim()) return; + setCreating(true); + try { + const { gallery } = await photosApi.createGallery({ + title: form.title.trim(), + titleEs: form.titleEs.trim() || undefined, + eventId: form.eventId || undefined, + visibility: form.visibility, + }); + setGalleries((prev) => [gallery, ...prev]); + setShowCreate(false); + setForm({ title: '', titleEs: '', eventId: '', visibility: 'private' }); + toast.success(locale === 'es' ? 'Galería creada' : 'Gallery created'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to create gallery'); + } finally { + setCreating(false); + } + }; + + if (loading) { + return ( +
+
+ + +
+ +
+ ); + } + + return ( +
+
+

+ {locale === 'es' ? 'Fotos de Eventos' : 'Event Photos'} +

+ +
+ + {galleries.length === 0 ? ( + + +

+ {locale === 'es' ? 'Sin galerías todavía' : 'No galleries yet'} +

+

+ {locale === 'es' + ? 'Crea una galería para compartir las fotos de un evento' + : 'Create a gallery to share photos from a past event'} +

+ +
+ ) : ( +
+ {galleries.map((g) => ( + + +
+ {g.coverUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+
+
+

+ {locale === 'es' && g.titleEs ? g.titleEs : g.title} +

+ +
+

+ {g.photoCount} {locale === 'es' ? 'fotos' : 'photos'} + {g.event && <> · {locale === 'es' && g.event.titleEs ? g.event.titleEs : g.event.title}} +

+
+
+ + ))} +
+ )} + + {showCreate && ( +
+ +
+

+ {locale === 'es' ? 'Nueva Galería' : 'New Gallery'} +

+ +
+
+
+ + setForm({ ...form, title: e.target.value })} + className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray" + placeholder="June Language Exchange" + /> +
+
+ + setForm({ ...form, titleEs: e.target.value })} + className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray" + placeholder="Intercambio de Junio" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts index 70168eb..940bc2a 100644 --- a/frontend/src/app/sitemap.ts +++ b/frontend/src/app/sitemap.ts @@ -2,6 +2,26 @@ import { MetadataRoute } from 'next'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py'; const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +interface SitemapPhotoGallery { + slug: string; + updatedAt: string; + event?: { slug: string }; +} + +/** Public photo galleries from the photo-api service (public visibility only). */ +async function getPublicPhotoGalleries(): Promise { + try { + const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, { + next: { revalidate: 3600 }, + }); + if (!res.ok) return []; + return ((await res.json()).galleries as SitemapPhotoGallery[]) || []; + } catch { + return []; + } +} interface SitemapEvent { id: string; @@ -41,7 +61,10 @@ async function getIndexableEvents(): Promise { } export default async function sitemap(): Promise { - const events = await getIndexableEvents(); + const [events, photoGalleries] = await Promise.all([ + getIndexableEvents(), + getPublicPhotoGalleries(), + ]); const now = new Date(); // Static pages @@ -88,6 +111,12 @@ export default async function sitemap(): Promise { changeFrequency: 'monthly', priority: 0.6, }, + { + url: `${siteUrl}/photos`, + lastModified: now, + changeFrequency: 'weekly', + priority: 0.6, + }, // Legal pages { url: `${siteUrl}/legal/terms-policy`, @@ -120,5 +149,13 @@ export default async function sitemap(): Promise { }; }); - return [...staticPages, ...eventPages]; + const photoPages: MetadataRoute.Sitemap = photoGalleries.map((g) => ({ + // Event-linked galleries live under the event's URL. + url: g.event ? `${siteUrl}/events/${g.event.slug}/gallery` : `${siteUrl}/photos/${g.slug}`, + lastModified: g.updatedAt ? new Date(g.updatedAt) : now, + changeFrequency: 'monthly' as const, + priority: 0.5, + })); + + return [...staticPages, ...eventPages, ...photoPages]; } diff --git a/frontend/src/components/Lightbox.tsx b/frontend/src/components/Lightbox.tsx new file mode 100644 index 0000000..e9c5e91 --- /dev/null +++ b/frontend/src/components/Lightbox.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useEffect, useCallback, useState } from 'react'; +import { + XMarkIcon, + ChevronLeftIcon, + ChevronRightIcon, + ArrowDownTrayIcon, +} from '@heroicons/react/24/outline'; + +export interface LightboxItem { + id: string; + previewUrl: string; + downloadUrl: string; + filename?: string; +} + +interface LightboxProps { + items: LightboxItem[]; + index: number; + onClose: () => void; + onNavigate: (index: number) => void; + /** Extra per-item action buttons rendered in the top bar (admin use). */ + renderActions?: (item: LightboxItem, index: number) => React.ReactNode; +} + +/** + * Full-screen photo lightbox with keyboard and swipe navigation, in the + * style of the admin gallery preview modal (fixed inset-0 bg-black/90). + */ +export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) { + const [touchStartX, setTouchStartX] = useState(null); + const item = items[index]; + + const prev = useCallback(() => { + onNavigate(index > 0 ? index - 1 : items.length - 1); + }, [index, items.length, onNavigate]); + const next = useCallback(() => { + onNavigate(index < items.length - 1 ? index + 1 : 0); + }, [index, items.length, onNavigate]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + if (e.key === 'ArrowLeft') prev(); + if (e.key === 'ArrowRight') next(); + }; + window.addEventListener('keydown', onKey); + document.body.style.overflow = 'hidden'; + return () => { + window.removeEventListener('keydown', onKey); + document.body.style.overflow = ''; + }; + }, [onClose, prev, next]); + + if (!item) return null; + + return ( +
setTouchStartX(e.touches[0].clientX)} + onTouchEnd={(e) => { + if (touchStartX === null) return; + const dx = e.changedTouches[0].clientX - touchStartX; + if (dx > 60) prev(); + if (dx < -60) next(); + setTouchStartX(null); + }} + > + + +
e.stopPropagation()} + > + + + + {renderActions?.(item, index)} +
+ + {items.length > 1 && ( + <> + + + + )} + + {/* eslint-disable-next-line @next/next/no-img-element */} + e.stopPropagation()} + draggable={false} + /> + +
+ {index + 1} / {items.length} +
+ + {/* Preload neighbours so prev/next feels instant. */} +
+ {items.length > 1 && ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + )} +
+
+ ); +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 13efe12..b697e9a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -275,6 +275,7 @@ "contacts": "Messages", "emails": "Emails", "gallery": "Gallery", + "photos": "Photos", "settings": "Settings" }, "events": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 6c28fce..63446c3 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -275,6 +275,7 @@ "contacts": "Mensajes", "emails": "Emails", "gallery": "Galería", + "photos": "Fotos", "settings": "Configuración" }, "events": { diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index bc3c4c2..54eb58e 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -17,3 +17,11 @@ export { siteSettingsApi } from './siteSettings'; export { legalSettingsApi } from './legalSettings'; export { legalPagesApi } from './legalPages'; export { faqApi } from './faq'; +export { photosApi } from './photos'; +export type { + Photo, + PhotoGallery, + PhotoGalleryEvent, + GalleryVisibility, + CreateGalleryInput, +} from './photos'; diff --git a/frontend/src/lib/api/photos.ts b/frontend/src/lib/api/photos.ts new file mode 100644 index 0000000..9fc7da3 --- /dev/null +++ b/frontend/src/lib/api/photos.ts @@ -0,0 +1,176 @@ +import { fetchApi, API_BASE, getToken } from './client'; + +// Client for the standalone photo-api Go service (photo-api/), reachable +// under /api/photos via the Next rewrite (dev) or nginx (prod). + +export type GalleryVisibility = 'public' | 'private' | 'link' | 'ticket'; + +export interface PhotoGalleryEvent { + id: string; + slug: string; + title: string; + titleEs?: string; + startDatetime: string; + status: string; +} + +export interface PhotoGallery { + id: string; + slug: string; + title: string; + titleEs?: string; + description?: string; + descriptionEs?: string; + eventId?: string; + visibility: GalleryVisibility; + coverPhotoId?: string; + photoCount: number; + coverUrl?: string; + createdAt: string; + updatedAt: string; + event?: PhotoGalleryEvent; + // Present on admin responses only: + shareToken?: string; + shareUrl?: string; +} + +export interface Photo { + id: string; + galleryId: string; + position: number; + originalFilename?: string; + contentType: string; + sizeBytes: number; + width?: number; + height?: number; + takenAt?: string; + status: 'queued' | 'processing' | 'ready' | 'failed'; + lastError?: string; + createdAt: string; + urls: { + thumb?: string; + preview?: string; + original: string; + }; +} + +export interface CreateGalleryInput { + title: string; + titleEs?: string; + description?: string; + descriptionEs?: string; + eventId?: string; + visibility?: GalleryVisibility; +} + +export const photosApi = { + // Admin (requires admin/organizer token) + createGallery: (data: CreateGalleryInput) => + fetchApi<{ gallery: PhotoGallery }>('/api/photos/galleries', { + method: 'POST', + body: JSON.stringify(data), + }), + + getGalleries: (eventId?: string) => { + const query = eventId ? `?eventId=${encodeURIComponent(eventId)}` : ''; + return fetchApi<{ galleries: PhotoGallery[] }>(`/api/photos/galleries${query}`); + }, + + getGallery: (id: string) => + fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(`/api/photos/galleries/${id}`), + + updateGallery: (id: string, data: Partial & { coverPhotoId?: string }) => + fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + + deleteGallery: (id: string) => + fetchApi<{ message: string }>(`/api/photos/galleries/${id}`, { method: 'DELETE' }), + + rotateShareToken: (id: string) => + fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}/share-token`, { + method: 'POST', + }), + + uploadPhoto: async (galleryId: string, file: File) => { + const token = getToken(); + const formData = new FormData(); + formData.append('files', file); + + const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({ error: 'Upload failed' })); + throw new Error(errorData.error || 'Upload failed'); + } + return res.json() as Promise<{ photos: Photo[] }>; + }, + + /** + * Upload with byte-level progress (0..1). fetch() cannot report upload + * progress, so this uses XMLHttpRequest; used by the admin uploader panel. + */ + uploadPhotoWithProgress: ( + galleryId: string, + file: File, + onProgress: (fraction: number) => void + ) => + new Promise<{ photos: Photo[] }>((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`); + const token = getToken(); + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total); + }; + xhr.onload = () => { + try { + const body = JSON.parse(xhr.responseText || '{}'); + if (xhr.status >= 200 && xhr.status < 300) resolve(body); + else reject(new Error(body.error || `Upload failed (${xhr.status})`)); + } catch { + reject(new Error(`Upload failed (${xhr.status})`)); + } + }; + xhr.onerror = () => reject(new Error('Network error during upload')); + xhr.onabort = () => reject(new Error('Upload cancelled')); + const formData = new FormData(); + formData.append('files', file); + xhr.send(formData); + }), + + reorderPhotos: (galleryId: string, photoIds: string[]) => + fetchApi<{ message: string }>(`/api/photos/galleries/${galleryId}/order`, { + method: 'PATCH', + body: JSON.stringify({ photoIds }), + }), + + deletePhoto: (photoId: string) => + fetchApi<{ message: string }>(`/api/photos/photos/${photoId}`, { method: 'DELETE' }), + + retryPhoto: (photoId: string) => + fetchApi<{ photo: Photo }>(`/api/photos/photos/${photoId}/retry`, { method: 'POST' }), + + // Viewer (anonymous or member token; share token via query param) + getPublicGalleries: () => + fetchApi<{ galleries: PhotoGallery[] }>('/api/photos/public/galleries'), + + getPublicGallery: (slug: string, token?: string) => { + const query = token ? `?token=${encodeURIComponent(token)}` : ''; + return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>( + `/api/photos/public/galleries/${encodeURIComponent(slug)}${query}` + ); + }, + + /** Newest gallery linked to an event; backs /events/[slug]/gallery. */ + getEventGallery: (eventSlug: string, token?: string) => { + const query = token ? `?token=${encodeURIComponent(token)}` : ''; + return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>( + `/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery${query}` + ); + }, +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 7b28589..2d5e51c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -18,9 +22,19 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next-build/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/package.json b/package.json index 57e81b3..66cbe08 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,16 @@ "private": true, "description": "Spanglish - Language Exchange Event Platform", "scripts": { - "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\" \"npm run dev:photos\"", "dev:backend": "npm run dev --workspace=backend", "dev:frontend": "npm run dev --workspace=frontend", + "dev:photos": "cd photo-api && go run ./cmd/photo-api", "build": "npm run build --workspaces", "build:backend": "npm run build --workspace=backend", "build:frontend": "npm run build --workspace=frontend", + "build:photos": "cd photo-api && go build -o bin/photo-api ./cmd/photo-api", + "test:photos": "cd photo-api && go test ./...", + "migrate:photos": "cd photo-api && go run ./cmd/photo-api migrate", "start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"", "start:backend": "npm run start --workspace=backend", "start:frontend": "npm run start --workspace=frontend", diff --git a/photo-api/.env.example b/photo-api/.env.example new file mode 100644 index 0000000..e1ea7b8 --- /dev/null +++ b/photo-api/.env.example @@ -0,0 +1,43 @@ +# Spanglish photo-api configuration +# Copy to .env — the service loads it on start (systemd also passes it via +# EnvironmentFile). This service has its own .env; nothing is shared with +# backend/.env except the VALUES of JWT_SECRET and DATABASE_URL. + +# Port (dev default 3003; the systemd unit overrides to 3020 in production) +PORT=3003 + +# Database — the SAME database the backend uses. photo-api owns only the +# photos_* tables (created by `photo-api migrate`) and reads users/events/ +# tickets/payments for auth and access checks. +# DB_TYPE must match the backend's engine: postgres | sqlite +DB_TYPE=postgres +DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db +# For sqlite instead: +# DB_TYPE=sqlite +# DATABASE_URL=../backend/data/spanglish.db + +# MUST be identical to JWT_SECRET in backend/.env — photo-api validates the +# same HS256 tokens the backend issues. +JWT_SECRET= + +# Public site origin, used to build share links and allow dev CORS +FRONTEND_URL=https://spanglishcommunity.com + +# Photo storage. Local disk by default; setting BOTH S3_ENDPOINT and +# S3_BUCKET switches to S3 (same convention as the backend's media storage). +# Use a photos-specific bucket — do not reuse the backend's media bucket. +STORAGE_PATH=./data/photos +#S3_ENDPOINT= +#S3_REGION=auto +#S3_BUCKET=spanglish-photos +#S3_ACCESS_KEY_ID= +#S3_SECRET_ACCESS_KEY= +#S3_FORCE_PATH_STYLE=true + +# Upload and processing tuning +MAX_UPLOAD_MB=50 +WORKER_CONCURRENCY=2 + +# HEIC conversion shells out to a host-installed CLI (autodetects `vips` +# then `heif-convert`; Debian: apt install libvips-tools). Set to override. +#HEIC_CONVERTER= diff --git a/photo-api/Dockerfile b/photo-api/Dockerfile new file mode 100644 index 0000000..a55cb59 --- /dev/null +++ b/photo-api/Dockerfile @@ -0,0 +1,25 @@ +# Secondary deployment path (the primary is bare-metal systemd, see +# deploy/spanglish-photos.service). Built for the docker-compose "scale" +# setup or any container host. +# +# docker build -t spanglish-photo-api photo-api/ +# docker run --env-file photo-api/.env -p 3020:3020 spanglish-photo-api + +FROM golang:1.26 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /photo-api ./cmd/photo-api + +# debian-slim rather than distroless: HEIC conversion needs the libvips CLI. +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-tools ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /photo-api /usr/local/bin/photo-api +RUN useradd -r -u 10001 photoapi && mkdir -p /data/photos && chown photoapi /data/photos +USER photoapi +ENV STORAGE_PATH=/data/photos +EXPOSE 3020 +ENTRYPOINT ["/usr/local/bin/photo-api"] diff --git a/photo-api/Makefile b/photo-api/Makefile new file mode 100644 index 0000000..6fcfefb --- /dev/null +++ b/photo-api/Makefile @@ -0,0 +1,23 @@ +.PHONY: start build test migrate clean help + +BINARY := bin/photo-api +CMD := ./cmd/photo-api + +help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}' + +start: ## Run the photo-api server (go run) + go run $(CMD) + +build: ## Build binary to bin/photo-api + go build -o $(BINARY) $(CMD) + +test: ## Run all Go tests + go test ./... + +migrate: ## Apply pending photos_* migrations + go run $(CMD) migrate + +clean: ## Remove built binary + rm -rf bin diff --git a/photo-api/PLAN.md b/photo-api/PLAN.md new file mode 100644 index 0000000..fb002eb --- /dev/null +++ b/photo-api/PLAN.md @@ -0,0 +1,334 @@ +# 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 ~716–738). `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 236–246). Payload: `{ sub: userId, email, role, tokenVersion, iat, exp }`. Transport is `Authorization: Bearer `; 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/`; 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): + +```sql +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 `. "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/?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|original`. Same access check as its gallery. Local: streamed with long-lived `Cache-Control` (keyed URLs are stable); S3: 302 to a presigned GET (15 min). `original` adds `Content-Disposition: attachment; filename=""`. | image bytes / 302 | +| `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//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: + +```json +"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 + +```go +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//orig/.`, `.../thumb/.jpg`, `.../preview/.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 ~1–3 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): + +```sql +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`. +- **Admin** — `src/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. +- **Public** — `src/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`: + +```bash +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.example`s, 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. diff --git a/photo-api/README.md b/photo-api/README.md new file mode 100644 index 0000000..624e6f9 --- /dev/null +++ b/photo-api/README.md @@ -0,0 +1,92 @@ +# photo-api + +Standalone Go service for event photo galleries: admins upload photos from +past events, group them into galleries, and share them with the community. +Lives in this monorepo but is its own module, binary, and deployable unit. +Design/decisions: [PLAN.md](./PLAN.md). + +## What it does + +- Galleries with four visibility modes: `public` (listed on /photos), + `private` (admins only), `link` (share-token URL), `ticket` (logged-in + users with a confirmed ticket for the linked event — any confirmed ticket + when the event is free). +- Originals are stored byte-identical for download; a worker generates JPEG + variants (thumb 512px q78, preview 2048px q85, EXIF stripped/orientation + applied) queued in the DB with retries. +- Storage: local disk (`STORAGE_PATH`) or S3/Garage/MinIO (set `S3_ENDPOINT` + + `S3_BUCKET`), same selection convention as the backend. S3 downloads use + short-lived presigned URLs; nothing in the bucket is public. +- Auth: validates the backend's HS256 JWTs with the shared `JWT_SECRET` + (issuer `spanglish`, audience `spanglish-app`) including the DB-backed + tokenVersion/account-status revocation check. Admin surface is + `admin`/`organizer` only. +- Database: the same Postgres or SQLite database as the backend (`DB_TYPE`, + `DATABASE_URL`). This service owns only the `photos_*` tables via its own + embedded migrations (`photo-api migrate`); drizzle-kit never sees them. + Reads of backend tables are confined to `internal/store/access.go` and + sanity-checked at startup. + +## Develop + +```bash +cp .env.example .env # set JWT_SECRET + DATABASE_URL to match backend/.env +go run ./cmd/photo-api migrate +go run ./cmd/photo-api # serves on :3003 (dev) +go test ./... # SQLite; add PHOTO_TEST_PG= to also run on Postgres +``` + +From the repo root: `npm run dev:photos`, `npm run build:photos`, +`npm run test:photos`. The Next dev server rewrites `/api/photos/*` to +`PHOTO_API_URL` (default `http://localhost:3003`), so the frontend needs no +extra config in dev. + +HEIC uploads require a converter CLI on the host: `apt install libvips-tools` +(or `libheif-examples`). Without one, HEIC uploads are rejected with a clear +message and a startup warning is logged. + +## API + +Everything under `/api/photos`. Errors are `{"error": string}`. + +Admin (Bearer token, role admin/organizer): + +| Method | Path | Purpose | +|---|---|---| +| POST | `/api/photos/galleries` | create gallery | +| GET | `/api/photos/galleries` | list all (`?eventId=`) | +| GET | `/api/photos/galleries/:id` | gallery + photos (all statuses) | +| PATCH | `/api/photos/galleries/:id` | update title/visibility/event/cover | +| DELETE | `/api/photos/galleries/:id` | delete gallery + objects | +| POST | `/api/photos/galleries/:id/photos` | multipart upload (`files`) | +| PATCH | `/api/photos/galleries/:id/order` | reorder (`{photoIds}`) | +| POST | `/api/photos/galleries/:id/share-token` | rotate share token | +| DELETE | `/api/photos/photos/:photoId` | delete photo | +| POST | `/api/photos/photos/:photoId/retry` | requeue failed photo | + +Viewer (anonymous or member token; `?token=` carries the share token): + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/photos/public/galleries` | public index | +| GET | `/api/photos/public/galleries/:slug` | gallery view (access-checked) | +| GET | `/api/photos/public/events/:eventSlug/gallery` | newest gallery of an event (access-checked) | +| GET | `/api/photos/files/:photoId/:variant` | bytes; `thumb\|preview\|original` | +| GET | `/api/photos/health` | liveness | + +## Deploy (systemd, primary) + +```bash +cd photo-api && go build -o bin/photo-api ./cmd/photo-api +sudo apt install libvips-tools +sudo cp ../deploy/spanglish-photos.service /etc/systemd/system/ +sudo systemctl daemon-reload && sudo systemctl enable --now spanglish-photos +sudo nginx -t && sudo systemctl reload nginx # after installing the updated confs +``` + +nginx routes `location ^~ /api/photos/` → `127.0.0.1:3020` (see +`deploy/front-end_nginx.conf`, `deploy/back-end_nginx.conf`, +`deploy/spanglish_upstreams.conf`). The frontend's production `.env` should +set `PHOTO_API_URL=http://127.0.0.1:3020` for server-side rendering of the +public gallery pages. A `Dockerfile` is included as a secondary path for the +compose-scale setup. diff --git a/photo-api/cmd/photo-api/main.go b/photo-api/cmd/photo-api/main.go new file mode 100644 index 0000000..549a1ee --- /dev/null +++ b/photo-api/cmd/photo-api/main.go @@ -0,0 +1,106 @@ +// photo-api serves event photo galleries for the Spanglish platform. +// +// photo-api start the HTTP server +// photo-api migrate apply pending photos_* migrations and exit +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/httpapi" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.SetPrefix("photo-api: ") + + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + db, err := store.Open(cfg) + if err != nil { + log.Fatalf("database: %v", err) + } + defer db.Close() + + if len(os.Args) > 1 && os.Args[1] == "migrate" { + if err := db.Migrate(context.Background()); err != nil { + log.Fatalf("migrate: %v", err) + } + log.Println("migrations up to date") + return + } + if len(os.Args) > 1 { + log.Fatalf("unknown subcommand %q (expected: migrate)", os.Args[1]) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := db.PingContext(ctx); err != nil { + log.Fatalf("database ping: %v", err) + } + // Fail fast if the columns read from Drizzle-owned tables changed. + if err := db.CheckMainSchema(ctx); err != nil { + log.Fatalf("%v", err) + } + + st, err := storage.New(cfg) + if err != nil { + log.Fatalf("storage: %v", err) + } + if cfg.S3Enabled() { + log.Printf("storage: S3 bucket %s at %s", cfg.S3Bucket, cfg.S3Endpoint) + } else { + log.Printf("storage: local disk at %s", cfg.StoragePath) + } + + heic := imaging.DetectHeicConverter(cfg.HeicConverter) + if heic == nil { + log.Println("WARNING: no HEIC converter found (install libvips-tools or libheif-examples); HEIC uploads will be rejected") + } else { + log.Printf("HEIC converter: %s", heic.Name()) + } + + if err := os.MkdirAll(cfg.StoragePath, 0o755); err != nil { + log.Fatalf("storage path: %v", err) + } + wrk := worker.New(db, st, heic, cfg.StoragePath, cfg.WorkerConcurrency) + wrk.Run(ctx) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: httpapi.New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk).Handler(), + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + go func() { + log.Printf("listening on :%d", cfg.Port) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("serve: %v", err) + } + }() + + <-ctx.Done() + log.Println("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown: %v", err) + } +} diff --git a/photo-api/go.mod b/photo-api/go.mod new file mode 100644 index 0000000..b25826d --- /dev/null +++ b/photo-api/go.mod @@ -0,0 +1,46 @@ +module git.azzamo.net/Michilis/Spanglish/photo-api + +go 1.26 + +require ( + github.com/aws/aws-sdk-go-v2 v1.43.0 + github.com/aws/aws-sdk-go-v2/config v1.32.31 + github.com/aws/aws-sdk-go-v2/credentials v1.19.30 + github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd + golang.org/x/image v0.44.0 + modernc.org/sqlite v1.54.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect + github.com/aws/smithy-go v1.27.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.40.0 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/photo-api/go.sum b/photo-api/go.sum new file mode 100644 index 0000000..aadd85a --- /dev/null +++ b/photo-api/go.sum @@ -0,0 +1,117 @@ +github.com/aws/aws-sdk-go-v2 v1.43.0 h1:fharf/WhbRAVZ1du0QL7roNFxZ6T/sWr+4Ni617bwSI= +github.com/aws/aws-sdk-go-v2 v1.43.0/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= +github.com/aws/aws-sdk-go-v2/config v1.32.31 h1:n4nY9O3QKoHIkL85EX+V8RcMFtOhlpTFhGArg915PXk= +github.com/aws/aws-sdk-go-v2/config v1.32.31/go.mod h1:PN0NYDCCoOpGGsZ2+elDUidmHfQBPyYzN2GCgl8HEBs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30 h1:TTCvvzFU6gXa4iJecNG/0F/B0oYTiazoRECr2XyLHrY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.30/go.mod h1:jKxAp2AEncnliinzpgOSZDFv6+VjvWhjw/AtbfsWT9U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31 h1:kfVL5wAunCJycL6MOQ6aNh6PlAYEymflcjuKmrWUA0o= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.31/go.mod h1:nWfRNDAppujCQgOUd43lKT4yeLv9z3nJ3bw1G3BgQKo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31 h1:Z8F3hfCY33IGpJjFAnv0wvtv1FIKj1GHmRDEYqy64tw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.31/go.mod h1:aVyUoytEyOViR6jhq6jula0xkc5NfBE2hgeF6BvOrao= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31 h1:hyOxUyXdh3AyjE93gBgsfziJag9ACwcs+ZpDBLzi8mw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.31/go.mod h1:OERqI9k0draSLB8O8woxY3q25ZWTELRK4RRoLMuMZFo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32 h1:0MrUL35H/Y4kdFfItoR5jCgtDQ4Z/8LudAoIHRfA4hE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.32/go.mod h1:2tNZkuWz54arj8mHVf+8Y7cKkcD8Wr/fBpENgEXpjLc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24 h1:mdPwDQPqxlw9Sc62Nt15yjEcARaDbPXkjRYtXsUripo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.24/go.mod h1:ls5ytnwLTcQaUu32fMYXFI3MjpKuTwL840PAm9iqyEg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31 h1:w2SIhW92DZPFrSL4ksVCr8IYff5OZwIcxg8+95tzvAI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.31/go.mod h1:wAhpCQbkov+IcvjozJbd2xRCoZybUEHNkcFunssNACg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32 h1:jWXtZdCnhXa9sGFixRaU2AxT4DIVse9HS4E2f+/KwV0= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.32/go.mod h1:9JS1UpfVvyD/ZPX8GsKb/Pq8scEM+7GP5fqh9SwH7po= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0 h1:7QZWVJZWzHivHWIa+5TELLaBBkbuoj0GPwQtMlJ0sqk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0/go.mod h1:fcvq5L7dK+5cQFicEJwpI6e6Wn8NY2i6yT5wRLYVc7s= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0 h1:OHH5iTQvVGmfHjX/5Q+vFuA/Rf2x6/95aJ/75QCQSm4= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.0/go.mod h1:mCF3AK9PpL49oOrhniUXWAfhVBVQ/XbytoE5eccZUIs= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0 h1:CaJyYhxBE0M/HJX/YvSaSmQlsI91VHB0lKU8LtLxL3A= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.0/go.mod h1:+e6BMRMPjBQoCw/WovYR9GLy2IU0z4Q77smOB1DraSg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 h1:tC323YV77QdafeBr6LUhLDTsboyuyHLNRwAyCP44kGU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0/go.mod h1:SfLK1sgviHmbI+MozR9iDwDjL4cdCVZtahsjoR+z7wg= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78vpjFZ7PRUj44mJc= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/photo-api/internal/auth/auth.go b/photo-api/internal/auth/auth.go new file mode 100644 index 0000000..ccea98a --- /dev/null +++ b/photo-api/internal/auth/auth.go @@ -0,0 +1,99 @@ +// Package auth validates the JWTs minted by backend/src/lib/auth.ts: +// HS256 with the shared JWT_SECRET, issuer "spanglish", audience +// "spanglish-app", plus the same DB-backed tokenVersion / account_status +// revocation check the backend's getAuthUser performs. +package auth + +import ( + "errors" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +const ( + issuer = "spanglish" + audience = "spanglish-app" +) + +var ( + ErrNoToken = errors.New("no bearer token") + ErrInvalidToken = errors.New("invalid token") +) + +// AdminRoles mirrors backend/src/routes/media.ts: photo administration is +// admin/organizer only (review decision #6). +var AdminRoles = []string{"admin", "organizer"} + +type User struct { + ID string + Email string + Role string +} + +func (u User) IsAdmin() bool { + for _, r := range AdminRoles { + if u.Role == r { + return true + } + } + return false +} + +type claims struct { + Email string `json:"email"` + Role string `json:"role"` + TokenVersion *int `json:"tokenVersion"` + jwt.RegisteredClaims +} + +type Verifier struct { + secret []byte + db *store.DB +} + +func NewVerifier(secret string, db *store.DB) *Verifier { + return &Verifier{secret: []byte(secret), db: db} +} + +// FromRequest returns the authenticated user, ErrNoToken when no +// Authorization header is present, or ErrInvalidToken for anything bad. +func (v *Verifier) FromRequest(r *http.Request) (User, error) { + header := r.Header.Get("Authorization") + if header == "" { + return User{}, ErrNoToken + } + raw, ok := strings.CutPrefix(header, "Bearer ") + if !ok { + return User{}, ErrInvalidToken + } + + var c claims + _, err := jwt.ParseWithClaims(raw, &c, func(*jwt.Token) (any, error) { return v.secret, nil }, + jwt.WithValidMethods([]string{"HS256"}), + jwt.WithIssuer(issuer), + jwt.WithAudience(audience), + jwt.WithExpirationRequired(), + ) + if err != nil || c.Subject == "" { + return User{}, ErrInvalidToken + } + + // DB-backed revocation, same semantics as backend getAuthUser + // (backend/src/lib/auth.ts:320-327). + ua, err := v.db.GetUserAuth(r.Context(), c.Subject) + if err != nil { + return User{}, ErrInvalidToken + } + if ua.AccountStatus != "active" { + return User{}, ErrInvalidToken + } + if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion { + return User{}, ErrInvalidToken + } + // Role from the DB, not the token, so demotions apply immediately. + return User{ID: ua.ID, Email: c.Email, Role: ua.Role}, nil +} diff --git a/photo-api/internal/config/config.go b/photo-api/internal/config/config.go new file mode 100644 index 0000000..0fa0f03 --- /dev/null +++ b/photo-api/internal/config/config.go @@ -0,0 +1,126 @@ +// Package config loads photo-api configuration from the environment, +// following the backend's convention of a per-service .env file with +// ad-hoc defaults (backend/src/index.ts uses dotenv the same way). +package config + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +type Config struct { + Port int + DBType string // "postgres" | "sqlite", same convention as backend DB_TYPE + DatabaseURL string + JWTSecret string + + StoragePath string + S3Endpoint string + S3Region string + S3Bucket string + S3AccessKeyID string + S3SecretKey string + S3ForcePathStyle bool + + MaxUploadMB int + WorkerConcurrency int + FrontendURL string + HeicConverter string // optional explicit converter command; autodetected when empty +} + +// S3Enabled mirrors backend/src/lib/storage.ts: S3 is active when both +// S3_ENDPOINT and S3_BUCKET are set. +func (c Config) S3Enabled() bool { + return c.S3Endpoint != "" && c.S3Bucket != "" +} + +func Load() (Config, error) { + loadDotenv(".env") + + cfg := Config{ + Port: envInt("PORT", 3003), + DBType: strings.ToLower(env("DB_TYPE", "sqlite")), + DatabaseURL: env("DATABASE_URL", ""), + JWTSecret: env("JWT_SECRET", ""), + StoragePath: env("STORAGE_PATH", "./data/photos"), + S3Endpoint: env("S3_ENDPOINT", ""), + S3Region: env("S3_REGION", "auto"), + S3Bucket: env("S3_BUCKET", ""), + S3AccessKeyID: env("S3_ACCESS_KEY_ID", ""), + S3SecretKey: env("S3_SECRET_ACCESS_KEY", ""), + S3ForcePathStyle: envBool("S3_FORCE_PATH_STYLE", true), + MaxUploadMB: envInt("MAX_UPLOAD_MB", 50), + WorkerConcurrency: envInt("WORKER_CONCURRENCY", 2), + FrontendURL: strings.TrimRight(env("FRONTEND_URL", "http://localhost:3000"), "/"), + HeicConverter: env("HEIC_CONVERTER", ""), + } + + if cfg.DBType != "postgres" && cfg.DBType != "sqlite" { + return cfg, fmt.Errorf("DB_TYPE must be postgres or sqlite, got %q", cfg.DBType) + } + if cfg.DatabaseURL == "" { + return cfg, fmt.Errorf("DATABASE_URL is required") + } + if cfg.JWTSecret == "" { + return cfg, fmt.Errorf("JWT_SECRET is required (must match backend/.env)") + } + return cfg, nil +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func envBool(key string, def bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return def +} + +// loadDotenv is a minimal KEY=VALUE loader (comments and blank lines +// ignored, optional surrounding quotes stripped, existing env wins). +func loadDotenv(path string) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] { + val = val[1 : len(val)-1] + } + if _, exists := os.LookupEnv(key); !exists { + os.Setenv(key, val) + } + } +} diff --git a/photo-api/internal/httpapi/access.go b/photo-api/internal/httpapi/access.go new file mode 100644 index 0000000..e69086e --- /dev/null +++ b/photo-api/internal/httpapi/access.go @@ -0,0 +1,55 @@ +package httpapi + +import ( + "net/http" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +// accessDenial describes why a viewer may not see a gallery. Non-public +// galleries return 404 (not 403) so their existence is not probeable — the +// exception is ticket mode, whose 401/403 lets the frontend prompt login +// or explain the attendee requirement (its existence is already public via +// the event). +type accessDenial struct { + status int + msg string +} + +// authorize is the single access-control decision point (PLAN.md §7); it is +// called from both the gallery-view handler and the file handler so every +// byte served re-checks. +func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, token string) *accessDenial { + if user != nil && user.IsAdmin() { + return nil + } + switch g.Visibility { + case store.VisibilityPublic: + return nil + case store.VisibilityLink: + if token != "" && token == g.ShareToken { + return nil + } + return &accessDenial{http.StatusNotFound, "Gallery not found"} + case store.VisibilityTicket: + // The share token is honored as an escape hatch (e.g. attendee +1s + // without accounts, at the admin's discretion). + if token != "" && token == g.ShareToken { + return nil + } + if user == nil { + return &accessDenial{http.StatusUnauthorized, "Authentication required"} + } + if g.EventID == "" { + return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"} + } + ok, err := s.db.HasPaidTicket(r.Context(), user.ID, g.EventID) + if err != nil || !ok { + return &accessDenial{http.StatusForbidden, "This gallery is only available to event attendees"} + } + return nil + default: // private + return &accessDenial{http.StatusNotFound, "Gallery not found"} + } +} diff --git a/photo-api/internal/httpapi/api_test.go b/photo-api/internal/httpapi/api_test.go new file mode 100644 index 0000000..525e821 --- /dev/null +++ b/photo-api/internal/httpapi/api_test.go @@ -0,0 +1,504 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + + "image" + "image/color" + "image/jpeg" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker" +) + +const testSecret = "test-secret" + +// Seed ids are UUID-formatted because the Postgres dialect uses uuid columns. +const ( + uAdmin = "11111111-1111-1111-1111-111111111111" + uMember = "22222222-2222-2222-2222-222222222222" + uBuyer = "33333333-3333-3333-3333-333333333333" + uPending = "44444444-4444-4444-4444-444444444444" + uFree = "55555555-5555-5555-5555-555555555555" + evPaid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + evFree = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +) + +type testEnv struct { + handler http.Handler + db *store.DB + worker *worker.Worker +} + +// setup migrates a scratch DB (SQLite by default; Postgres when +// PHOTO_TEST_PG holds a connection URL), seeds the minimal slice of the +// main app schema that access.go reads, and returns a ready handler. +func setup(t *testing.T) *testEnv { + t.Helper() + dir := t.TempDir() + cfg := config.Config{ + Port: 0, + DBType: "sqlite", + DatabaseURL: filepath.Join(dir, "test.db"), + JWTSecret: testSecret, + StoragePath: filepath.Join(dir, "photos"), + MaxUploadMB: 5, + WorkerConcurrency: 1, + FrontendURL: "http://localhost:3000", + } + if pgURL := os.Getenv("PHOTO_TEST_PG"); pgURL != "" { + cfg.DBType = "postgres" + cfg.DatabaseURL = pgURL + } + db, err := store.Open(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if cfg.DBType == "postgres" { + // The scratch Postgres DB persists across tests; start clean. + if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments CASCADE`); err != nil { + t.Fatal(err) + } + } + if err := db.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + + idType := "text" + if cfg.DBType == "postgres" { + idType = "uuid" + } + seed := []string{ + `CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, token_version integer NOT NULL DEFAULT 0, account_status text NOT NULL DEFAULT 'active')`, + `CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`, + `CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`, + `CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`, + + `INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin',0,'active')`, + `INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user',0,'active')`, + `INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user',0,'active')`, + `INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user',0,'active')`, + `INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user',0,'active')`, + + `INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`, + `INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`, + + `INSERT INTO tickets VALUES ('t1','` + uBuyer + `','` + evPaid + `','confirmed')`, + `INSERT INTO payments VALUES ('p1','t1','paid')`, + `INSERT INTO tickets VALUES ('t2','` + uPending + `','` + evPaid + `','pending')`, + `INSERT INTO payments VALUES ('p2','t2','pending')`, + `INSERT INTO tickets VALUES ('t3','` + uFree + `','` + evFree + `','confirmed')`, + } + for _, q := range seed { + if _, err := db.Exec(q); err != nil { + t.Fatalf("seed %q: %v", q, err) + } + } + + st, err := storage.New(cfg) + if err != nil { + t.Fatal(err) + } + wrk := worker.New(db, st, nil, cfg.StoragePath, 1) + srv := New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk) + return &testEnv{handler: srv.Handler(), db: db, worker: wrk} +} + +func makeToken(t *testing.T, sub, email, role string) string { + t.Helper() + tv := 0 + claims := jwt.MapClaims{ + "sub": sub, "email": email, "role": role, "tokenVersion": tv, + "iss": "spanglish", "aud": "spanglish-app", + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + } + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testSecret)) + if err != nil { + t.Fatal(err) + } + return s +} + +func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var reader *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + reader = bytes.NewReader(b) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + e.handler.ServeHTTP(w, req) + return w +} + +func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T { + t.Helper() + var v T + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatalf("decode %s: %v", w.Body.String(), err) + } + return v +} + +func testJPEG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 800, 600)) + for x := 0; x < 800; x += 10 { + for y := 0; y < 600; y++ { + img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255}) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestHealth(t *testing.T) { + e := setup(t) + if w := e.request(t, "GET", "/api/photos/health", "", nil); w.Code != 200 { + t.Fatalf("health: %d", w.Code) + } +} + +func TestAdminGate(t *testing.T) { + e := setup(t) + body := map[string]string{"title": "Test"} + if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 { + t.Fatalf("anon create: want 401, got %d", w.Code) + } + member := makeToken(t, uMember, "m@x.py", "user") + if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 { + t.Fatalf("member create: want 403, got %d", w.Code) + } + unknown := makeToken(t, newID(), "g@x.py", "admin") + if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 { + t.Fatalf("unknown-user token: want 401, got %d", w.Code) + } +} + +type galleryResp struct { + Gallery galleryJSON `json:"gallery"` + Photos []photoJSON `json:"photos"` +} + +func createGallery(t *testing.T, e *testEnv, admin string, body map[string]any) galleryJSON { + t.Helper() + w := e.request(t, "POST", "/api/photos/galleries", admin, body) + if w.Code != 201 { + t.Fatalf("create gallery: %d %s", w.Code, w.Body.String()) + } + return decode[galleryResp](t, w).Gallery +} + +func uploadPhoto(t *testing.T, e *testEnv, admin, galleryID string, file []byte) photoJSON { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("files", "test photo.jpg") + fw.Write(file) + mw.Close() + req := httptest.NewRequest("POST", "/api/photos/galleries/"+galleryID+"/photos", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+admin) + w := httptest.NewRecorder() + e.handler.ServeHTTP(w, req) + if w.Code != 201 { + t.Fatalf("upload: %d %s", w.Code, w.Body.String()) + } + photos := decode[struct { + Photos []photoJSON `json:"photos"` + }](t, w).Photos + if len(photos) != 1 { + t.Fatalf("want 1 photo, got %d", len(photos)) + } + return photos[0] +} + +// processQueue runs the worker until the photo is ready or failed. +func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + e.worker.Run(ctx) + e.worker.Nudge() + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + p, err := e.db.GetPhoto(ctx, photoID) + if err != nil { + t.Fatal(err) + } + if p.Status == "ready" || (p.Status == "failed" && p.Attempts > 0) { + return p + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("timeout waiting for processing") + return store.Photo{} +} + +func TestUploadProcessAndServe(t *testing.T) { + e := setup(t) + admin := makeToken(t, uAdmin, "a@x.py", "admin") + g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"}) + if g.Slug != "fiesta-de-junio" { + t.Fatalf("slug: %q", g.Slug) + } + // Public galleries share a clean URL: the token grants nothing there. + if g.ShareToken == "" { + t.Fatalf("share token missing: %+v", g) + } + if strings.Contains(g.ShareURL, "token=") { + t.Fatalf("public gallery share url must not carry a token: %s", g.ShareURL) + } + if !strings.HasSuffix(g.ShareURL, "/photos/"+g.Slug) { + t.Fatalf("public share url: %s", g.ShareURL) + } + + p := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) + if p.Status != "queued" || p.ContentType != "image/jpeg" { + t.Fatalf("uploaded photo: %+v", p) + } + done := processQueue(t, e, p.ID) + if done.Status != "ready" { + t.Fatalf("processing failed: %s", done.LastError) + } + if done.Width != 800 || done.Height != 600 { + t.Fatalf("dimensions: %dx%d", done.Width, done.Height) + } + + for _, variant := range []string{"thumb", "preview", "original"} { + w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/"+variant, "", nil) + if w.Code != 200 { + t.Fatalf("%s: %d", variant, w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" { + t.Fatalf("%s content type: %s", variant, ct) + } + } + w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil) + if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, "test photo.jpg") { + t.Fatalf("original disposition: %q", cd) + } + + // Bad upload is rejected by magic bytes. + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("files", "notes.txt") + fw.Write([]byte("not an image at all")) + mw.Close() + req := httptest.NewRequest("POST", "/api/photos/galleries/"+g.ID+"/photos", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+admin) + rec := httptest.NewRecorder() + e.handler.ServeHTTP(rec, req) + if rec.Code != 415 { + t.Fatalf("text upload: want 415, got %d %s", rec.Code, rec.Body.String()) + } +} + +func TestAccessMatrix(t *testing.T) { + e := setup(t) + admin := makeToken(t, uAdmin, "a@x.py", "admin") + member := makeToken(t, uMember, "m@x.py", "user") + buyer := makeToken(t, uBuyer, "b@x.py", "user") + pending := makeToken(t, uPending, "p@x.py", "user") + freeguy := makeToken(t, uFree, "f@x.py", "user") + + get := func(slug, token, bearer string) int { + path := "/api/photos/public/galleries/" + slug + if token != "" { + path += "?token=" + token + } + return e.request(t, "GET", path, bearer, nil).Code + } + + // private + priv := createGallery(t, e, admin, map[string]any{"title": "Privada", "visibility": "private"}) + for name, code := range map[string]int{"anon": get(priv.Slug, "", ""), "member": get(priv.Slug, "", member), + "with-token": get(priv.Slug, priv.ShareToken, "")} { + if code != 404 { + t.Errorf("private/%s: want 404, got %d", name, code) + } + } + if got := get(priv.Slug, "", admin); got != 200 { + t.Errorf("private/admin: want 200, got %d", got) + } + + // link + link := createGallery(t, e, admin, map[string]any{"title": "Enlace", "visibility": "link"}) + if got := get(link.Slug, "", ""); got != 404 { + t.Errorf("link/anon: want 404, got %d", got) + } + if got := get(link.Slug, "wrong-token", ""); got != 404 { + t.Errorf("link/bad-token: want 404, got %d", got) + } + if got := get(link.Slug, link.ShareToken, ""); got != 200 { + t.Errorf("link/token: want 200, got %d", got) + } + + // ticket, paid event + tick := createGallery(t, e, admin, map[string]any{"title": "Con Entrada", "visibility": "ticket", "eventId": evPaid}) + if got := get(tick.Slug, "", ""); got != 401 { + t.Errorf("ticket/anon: want 401, got %d", got) + } + if got := get(tick.Slug, "", member); got != 403 { + t.Errorf("ticket/no-ticket: want 403, got %d", got) + } + if got := get(tick.Slug, "", pending); got != 403 { + t.Errorf("ticket/pending-payment: want 403, got %d", got) + } + if got := get(tick.Slug, "", buyer); got != 200 { + t.Errorf("ticket/paid: want 200, got %d", got) + } + if got := get(tick.Slug, tick.ShareToken, ""); got != 200 { + t.Errorf("ticket/share-token: want 200, got %d", got) + } + + // ticket, free event: any confirmed ticket counts (review decision) + free := createGallery(t, e, admin, map[string]any{"title": "Evento Gratis", "visibility": "ticket", "eventId": evFree}) + if got := get(free.Slug, "", freeguy); got != 200 { + t.Errorf("ticket/free-event-confirmed: want 200, got %d", got) + } + if got := get(free.Slug, "", member); got != 403 { + t.Errorf("ticket/free-event-no-ticket: want 403, got %d", got) + } + + // public index lists only public galleries + pub := createGallery(t, e, admin, map[string]any{"title": "Publica", "visibility": "public"}) + idx := decode[struct { + Galleries []galleryJSON `json:"galleries"` + }](t, e.request(t, "GET", "/api/photos/public/galleries", "", nil)) + if len(idx.Galleries) != 1 || idx.Galleries[0].ID != pub.ID { + t.Errorf("public index: %+v", idx.Galleries) + } + for _, g := range idx.Galleries { + if g.ShareToken != "" { + t.Errorf("public index leaks share token") + } + } +} + +func TestReorderAndVisibilityUpdate(t *testing.T) { + e := setup(t) + admin := makeToken(t, uAdmin, "a@x.py", "admin") + g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"}) + jpg := testJPEG(t) + p1 := uploadPhoto(t, e, admin, g.ID, jpg) + p2 := uploadPhoto(t, e, admin, g.ID, jpg) + + if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin, + map[string]any{"photoIds": []string{p2.ID, p1.ID}}); w.Code != 200 { + t.Fatalf("reorder: %d %s", w.Code, w.Body.String()) + } + detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil)) + if len(detail.Photos) != 2 || detail.Photos[0].ID != p2.ID { + t.Fatalf("order not applied: %+v", detail.Photos) + } + + if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin, + map[string]any{"photoIds": []string{p1.ID}}); w.Code != 400 { + t.Fatalf("partial reorder: want 400, got %d", w.Code) + } + + if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin, + map[string]any{"visibility": "banana"}); w.Code != 400 { + t.Fatalf("bad visibility: want 400, got %d", w.Code) + } + if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin, + map[string]any{"visibility": "link"}); w.Code != 200 { + t.Fatalf("set link: %d %s", w.Code, w.Body.String()) + } + if w := e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil); w.Code != 404 { + t.Fatalf("after switch to link, anon: want 404, got %d", w.Code) + } +} + +func TestEventGalleryRoute(t *testing.T) { + e := setup(t) + admin := makeToken(t, uAdmin, "a@x.py", "admin") + buyer := makeToken(t, uBuyer, "b@x.py", "user") + + g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid}) + + // Event-linked galleries share via the event URL. + if !strings.Contains(g.ShareURL, "/events/fiesta/gallery?token="+g.ShareToken) { + t.Fatalf("share url should use event route: %s", g.ShareURL) + } + + // /public/events/{slug}/gallery obeys the same access rules. + if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", "", nil); w.Code != 401 { + t.Fatalf("event gallery anon: want 401, got %d", w.Code) + } + w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", buyer, nil) + if w.Code != 200 { + t.Fatalf("event gallery buyer: want 200, got %d %s", w.Code, w.Body.String()) + } + if got := decode[galleryResp](t, w).Gallery.ID; got != g.ID { + t.Fatalf("wrong gallery: %s != %s", got, g.ID) + } + if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery?token="+g.ShareToken, "", nil); w.Code != 200 { + t.Fatalf("event gallery share token: want 200, got %d", w.Code) + } + + // Unknown event slug and event without a gallery are both 404. + if w := e.request(t, "GET", "/api/photos/public/events/nope/gallery", buyer, nil); w.Code != 404 { + t.Fatalf("unknown event: want 404, got %d", w.Code) + } + if w := e.request(t, "GET", "/api/photos/public/events/gratis/gallery", buyer, nil); w.Code != 404 { + t.Fatalf("event without gallery: want 404, got %d", w.Code) + } + + // A standalone gallery keeps the /photos share URL; link mode carries + // the token, public mode does not. + solo := createGallery(t, e, admin, map[string]any{"title": "Sin Evento", "visibility": "link"}) + if !strings.Contains(solo.ShareURL, "/photos/sin-evento?token=") { + t.Fatalf("standalone share url: %s", solo.ShareURL) + } + pubEvent := createGallery(t, e, admin, map[string]any{"title": "Publica Con Evento", "visibility": "public", "eventId": evFree}) + if !strings.HasSuffix(pubEvent.ShareURL, "/events/gratis/gallery") { + t.Fatalf("public event gallery share url should be the clean event link: %s", pubEvent.ShareURL) + } +} + +func TestSlugCollision(t *testing.T) { + e := setup(t) + admin := makeToken(t, uAdmin, "a@x.py", "admin") + a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"}) + b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"}) + if a.Slug == b.Slug { + t.Fatalf("slug collision: %s", a.Slug) + } + if b.Slug != "misma-fiesta-2" { + t.Fatalf("second slug: %s", b.Slug) + } + if a.Visibility != "private" { + t.Fatalf("default visibility: %s", a.Visibility) + } +} diff --git a/photo-api/internal/httpapi/dto.go b/photo-api/internal/httpapi/dto.go new file mode 100644 index 0000000..0fcf53a --- /dev/null +++ b/photo-api/internal/httpapi/dto.go @@ -0,0 +1,153 @@ +package httpapi + +import ( + "context" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +// JSON shapes are camelCase like the backend's responses. + +type eventJSON struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + TitleEs string `json:"titleEs,omitempty"` + StartDatetime string `json:"startDatetime"` + Status string `json:"status"` +} + +type galleryJSON struct { + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + TitleEs string `json:"titleEs,omitempty"` + Description string `json:"description,omitempty"` + DescriptionEs string `json:"descriptionEs,omitempty"` + EventID string `json:"eventId,omitempty"` + Visibility string `json:"visibility"` + CoverPhotoID string `json:"coverPhotoId,omitempty"` + PhotoCount int `json:"photoCount"` + CoverURL string `json:"coverUrl,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Event *eventJSON `json:"event,omitempty"` + // Admin-only fields: + ShareToken string `json:"shareToken,omitempty"` + ShareURL string `json:"shareUrl,omitempty"` +} + +type photoURLs struct { + Thumb string `json:"thumb,omitempty"` + Preview string `json:"preview,omitempty"` + Original string `json:"original"` +} + +type photoJSON struct { + ID string `json:"id"` + GalleryID string `json:"galleryId"` + Position int `json:"position"` + OriginalFilename string `json:"originalFilename,omitempty"` + ContentType string `json:"contentType"` + SizeBytes int64 `json:"sizeBytes"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + TakenAt string `json:"takenAt,omitempty"` + Status string `json:"status"` + LastError string `json:"lastError,omitempty"` // admin only + CreatedAt string `json:"createdAt"` + URLs photoURLs `json:"urls"` +} + +func isoTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format("2006-01-02T15:04:05.000Z") +} + +// fileURL builds the access-checked file endpoint URL; token is appended +// for link-mode viewers so the client can use URLs verbatim. +func fileURL(photoID, variant, token string) string { + u := "/api/photos/files/" + photoID + "/" + variant + if token != "" { + u += "?token=" + token + } + return u +} + +func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON { + out := photoJSON{ + ID: p.ID, + GalleryID: p.GalleryID, + Position: p.Position, + OriginalFilename: p.OriginalFilename, + ContentType: p.ContentType, + SizeBytes: p.SizeBytes, + Width: p.Width, + Height: p.Height, + TakenAt: isoTime(p.TakenAt), + Status: p.Status, + CreatedAt: isoTime(p.CreatedAt), + URLs: photoURLs{Original: fileURL(p.ID, "original", token)}, + } + if p.ThumbKey != "" { + out.URLs.Thumb = fileURL(p.ID, "thumb", token) + } + if p.PreviewKey != "" { + out.URLs.Preview = fileURL(p.ID, "preview", token) + } + if admin { + out.LastError = p.LastError + } + return out +} + +func (s *Server) galleryToJSON(ctx context.Context, g store.Gallery, token string, admin bool) galleryJSON { + out := galleryJSON{ + ID: g.ID, + Slug: g.Slug, + Title: g.Title, + TitleEs: g.TitleEs, + Description: g.Description, + DescriptionEs: g.DescriptionEs, + EventID: g.EventID, + Visibility: g.Visibility, + CoverPhotoID: g.CoverPhotoID, + PhotoCount: g.PhotoCount, + CreatedAt: isoTime(g.CreatedAt), + UpdatedAt: isoTime(g.UpdatedAt), + } + if coverID, _ := s.db.GalleryCoverKey(ctx, g); coverID != "" { + out.CoverURL = fileURL(coverID, "thumb", token) + } + if g.EventID != "" { + if ev, err := s.db.GetEventSummary(ctx, g.EventID); err == nil { + out.Event = &eventJSON{ + ID: ev.ID, + Slug: ev.Slug, + Title: ev.Title, + TitleEs: ev.TitleEs, + StartDatetime: isoTime(ev.StartDatetime), + Status: ev.Status, + } + } + } + if admin { + out.ShareToken = g.ShareToken + // Event-linked galleries live under the event's URL; standalone + // galleries keep their own /photos/ URL. + page := "/photos/" + g.Slug + if out.Event != nil { + page = "/events/" + out.Event.Slug + "/gallery" + } + out.ShareURL = s.cfg.FrontendURL + page + // The token only grants anything in link/ticket mode; public and + // private galleries get a clean URL. + if g.Visibility == store.VisibilityLink || g.Visibility == store.VisibilityTicket { + out.ShareURL += "?token=" + g.ShareToken + } + } + return out +} diff --git a/photo-api/internal/httpapi/files.go b/photo-api/internal/httpapi/files.go new file mode 100644 index 0000000..2ee44ea --- /dev/null +++ b/photo-api/internal/httpapi/files.go @@ -0,0 +1,117 @@ +package httpapi + +import ( + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" +) + +const presignExpiry = 15 * time.Minute + +// serveFile delivers photo bytes after re-running the gallery access check. +// S3: 302 to a short-lived presigned URL; local: streamed directly. +func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) { + variant := r.PathValue("variant") + if variant != "thumb" && variant != "preview" && variant != "original" { + writeError(w, http.StatusNotFound, "Not Found") + return + } + p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId")) + if err != nil { + writeStoreError(w, err, "Photo not found") + return + } + g, err := s.db.GetGallery(r.Context(), p.GalleryID) + if err != nil { + writeStoreError(w, err, "Photo not found") + return + } + user := s.optionalUser(r) + token := r.URL.Query().Get("token") + if denial := s.authorize(r, g, user, token); denial != nil { + writeError(w, denial.status, denial.msg) + return + } + isAdmin := user != nil && user.IsAdmin() + + var key, contentType, downloadName string + switch variant { + case "thumb": + key, contentType = p.ThumbKey, "image/jpeg" + case "preview": + key, contentType = p.PreviewKey, "image/jpeg" + case "original": + key, contentType = p.OriginalKey, p.ContentType + downloadName = p.OriginalFilename + if downloadName == "" { + downloadName = p.ID + extForContentType(p.ContentType) + } + } + if key == "" { + // Variant not generated yet (photo still processing). + writeError(w, http.StatusNotFound, "Not ready") + return + } + // Non-ready originals stay admin-only so uploads that fail processing + // never leak to viewers. + if p.Status != "ready" && !isAdmin { + writeError(w, http.StatusNotFound, "Not ready") + return + } + + if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil { + http.Redirect(w, r, url, http.StatusFound) + return + } else if !errors.Is(err, storage.ErrNoPresign) { + log.Printf("Error: presign %s: %v", key, err) + writeError(w, http.StatusInternalServerError, "Internal Server Error") + return + } + + reader, size, err := s.storage.Open(r.Context(), key) + if err != nil { + if os.IsNotExist(err) { + writeError(w, http.StatusNotFound, "Not Found") + return + } + log.Printf("Error: open %s: %v", key, err) + writeError(w, http.StatusInternalServerError, "Internal Server Error") + return + } + defer reader.Close() + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + w.Header().Set("X-Content-Type-Options", "nosniff") + // Keys are immutable (new upload = new key), so private caching is safe + // even though access is checked per request. + w.Header().Set("Cache-Control", "private, max-age=86400") + if downloadName != "" { + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", downloadName)) + } + if _, err := io.Copy(w, reader); err != nil { + log.Printf("stream %s: %v", key, err) + } +} + +func extForContentType(ct string) string { + switch ct { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "image/heic": + return ".heic" + } + return "" +} diff --git a/photo-api/internal/httpapi/galleries.go b/photo-api/internal/httpapi/galleries.go new file mode 100644 index 0000000..c5c2458 --- /dev/null +++ b/photo-api/internal/httpapi/galleries.go @@ -0,0 +1,225 @@ +package httpapi + +import ( + "log" + "net/http" + "strconv" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +func validVisibility(v string) bool { + switch v { + case store.VisibilityPublic, store.VisibilityPrivate, store.VisibilityLink, store.VisibilityTicket: + return true + } + return false +} + +type createGalleryBody struct { + Title string `json:"title"` + TitleEs string `json:"titleEs"` + Description string `json:"description"` + DescriptionEs string `json:"descriptionEs"` + EventID string `json:"eventId"` + Visibility string `json:"visibility"` +} + +func (s *Server) createGallery(w http.ResponseWriter, r *http.Request, user auth.User) { + var body createGalleryBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON body") + return + } + if body.Title == "" { + writeError(w, http.StatusBadRequest, "title is required") + return + } + if body.Visibility == "" { + body.Visibility = store.VisibilityPrivate + } + if !validVisibility(body.Visibility) { + writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket") + return + } + if body.EventID != "" { + if _, err := s.db.GetEventSummary(r.Context(), body.EventID); err != nil { + writeStoreError(w, err, "Event not found") + return + } + } + + slug, err := s.uniqueSlug(r.Context(), body.Title) + if err != nil { + writeStoreError(w, err, "") + return + } + now := time.Now() + g := store.Gallery{ + ID: newID(), + Slug: slug, + Title: body.Title, + TitleEs: body.TitleEs, + Description: body.Description, + DescriptionEs: body.DescriptionEs, + EventID: body.EventID, + Visibility: body.Visibility, + ShareToken: newShareToken(), + CreatedBy: user.ID, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.db.CreateGallery(r.Context(), g); err != nil { + writeStoreError(w, err, "") + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "gallery": s.galleryToJSON(r.Context(), g, "", true), + }) +} + +func (s *Server) listGalleries(w http.ResponseWriter, r *http.Request, _ auth.User) { + limit := queryInt(r, "limit", 100) + offset := queryInt(r, "offset", 0) + galleries, err := s.db.ListGalleries(r.Context(), r.URL.Query().Get("eventId"), limit, offset) + if err != nil { + writeStoreError(w, err, "") + return + } + out := make([]galleryJSON, 0, len(galleries)) + for _, g := range galleries { + out = append(out, s.galleryToJSON(r.Context(), g, "", true)) + } + writeJSON(w, http.StatusOK, map[string]any{"galleries": out}) +} + +func (s *Server) getGallery(w http.ResponseWriter, r *http.Request, _ auth.User) { + g, err := s.db.GetGallery(r.Context(), r.PathValue("id")) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + photos, err := s.db.ListPhotos(r.Context(), g.ID, false) + if err != nil { + writeStoreError(w, err, "") + return + } + out := make([]photoJSON, 0, len(photos)) + for _, p := range photos { + out = append(out, s.photoToJSON(p, "", true)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "gallery": s.galleryToJSON(r.Context(), g, "", true), + "photos": out, + }) +} + +type updateGalleryBody struct { + Title *string `json:"title"` + TitleEs *string `json:"titleEs"` + Description *string `json:"description"` + DescriptionEs *string `json:"descriptionEs"` + EventID *string `json:"eventId"` + Visibility *string `json:"visibility"` + CoverPhotoID *string `json:"coverPhotoId"` +} + +func (s *Server) updateGallery(w http.ResponseWriter, r *http.Request, _ auth.User) { + id := r.PathValue("id") + var body updateGalleryBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON body") + return + } + if body.Title != nil && *body.Title == "" { + writeError(w, http.StatusBadRequest, "title cannot be empty") + return + } + if body.Visibility != nil && !validVisibility(*body.Visibility) { + writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket") + return + } + if body.EventID != nil && *body.EventID != "" { + if _, err := s.db.GetEventSummary(r.Context(), *body.EventID); err != nil { + writeStoreError(w, err, "Event not found") + return + } + } + if body.CoverPhotoID != nil && *body.CoverPhotoID != "" { + p, err := s.db.GetPhoto(r.Context(), *body.CoverPhotoID) + if err != nil || p.GalleryID != id { + writeError(w, http.StatusBadRequest, "coverPhotoId must be a photo of this gallery") + return + } + } + + err := s.db.UpdateGallery(r.Context(), id, store.GalleryUpdate{ + Title: body.Title, + TitleEs: body.TitleEs, + Description: body.Description, + DescriptionEs: body.DescriptionEs, + EventID: body.EventID, + Visibility: body.Visibility, + CoverPhotoID: body.CoverPhotoID, + }) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + g, err := s.db.GetGallery(r.Context(), id) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "gallery": s.galleryToJSON(r.Context(), g, "", true), + }) +} + +func (s *Server) deleteGallery(w http.ResponseWriter, r *http.Request, _ auth.User) { + id := r.PathValue("id") + keys, err := s.db.PhotoKeys(r.Context(), id) + if err != nil { + writeStoreError(w, err, "") + return + } + if err := s.db.DeleteGallery(r.Context(), id); err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + // Object cleanup is best-effort after the rows are gone; orphaned + // objects are harmless (unreachable) and logged for manual sweep. + for _, key := range keys { + if err := s.storage.Delete(r.Context(), key); err != nil { + log.Printf("delete object %s: %v", key, err) + } + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Gallery deleted"}) +} + +func (s *Server) rotateShareToken(w http.ResponseWriter, r *http.Request, _ auth.User) { + id := r.PathValue("id") + if err := s.db.RotateShareToken(r.Context(), id, newShareToken()); err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + g, err := s.db.GetGallery(r.Context(), id) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "gallery": s.galleryToJSON(r.Context(), g, "", true), + }) +} + +func queryInt(r *http.Request, key string, def int) int { + if v := r.URL.Query().Get(key); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + return n + } + } + return def +} diff --git a/photo-api/internal/httpapi/middleware.go b/photo-api/internal/httpapi/middleware.go new file mode 100644 index 0000000..323e105 --- /dev/null +++ b/photo-api/internal/httpapi/middleware.go @@ -0,0 +1,36 @@ +package httpapi + +import ( + "net/http" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" +) + +// requireAdmin mirrors the backend's requireAuth(['admin','organizer']): +// 401 {"error":"Unauthorized"} without a valid token, 403 +// {"error":"Forbidden"} for valid non-admin users. +func (s *Server) requireAdmin(next func(http.ResponseWriter, *http.Request, auth.User)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user, err := s.verifier.FromRequest(r) + if err != nil { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + if !user.IsAdmin() { + writeError(w, http.StatusForbidden, "Forbidden") + return + } + next(w, r, user) + } +} + +// optionalUser returns the authenticated user or nil; an invalid token is +// treated as anonymous rather than an error, matching how public backend +// routes behave. +func (s *Server) optionalUser(r *http.Request) *auth.User { + user, err := s.verifier.FromRequest(r) + if err != nil { + return nil + } + return &user +} diff --git a/photo-api/internal/httpapi/photos.go b/photo-api/internal/httpapi/photos.go new file mode 100644 index 0000000..c230f5d --- /dev/null +++ b/photo-api/internal/httpapi/photos.go @@ -0,0 +1,242 @@ +package httpapi + +import ( + "bytes" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +// uploadPhotos accepts multipart form data with one or more "files" parts. +// Each file is sniffed by magic bytes (client filename/Content-Type are +// untrusted, same policy as /api/media/upload), stored as the untouched +// original, and queued for variant processing. +func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) { + galleryID := r.PathValue("id") + g, err := s.db.GetGallery(r.Context(), galleryID) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + + maxFile := int64(s.cfg.MaxUploadMB) << 20 + // Generous request ceiling; nginx enforces its own client_max_body_size. + r.Body = http.MaxBytesReader(w, r.Body, 40*maxFile) + mr, err := r.MultipartReader() + if err != nil { + writeError(w, http.StatusBadRequest, "Expected multipart/form-data") + return + } + + position, err := s.db.NextPosition(r.Context(), g.ID) + if err != nil { + writeStoreError(w, err, "") + return + } + + var created []photoJSON + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + writeError(w, http.StatusBadRequest, "Malformed multipart body") + return + } + if part.FormName() != "files" && part.FormName() != "file" { + part.Close() + continue + } + photo, uploadErr := s.saveUpload(r, g, part, position, maxFile) + part.Close() + if uploadErr != nil { + // One bad file fails the request explicitly rather than silently + // skipping it; the admin UI uploads files individually. + writeError(w, uploadErr.status, uploadErr.msg) + return + } + created = append(created, s.photoToJSON(photo, "", true)) + position++ + } + + if len(created) == 0 { + writeError(w, http.StatusBadRequest, "No file provided") + return + } + s.worker.Nudge() + writeJSON(w, http.StatusCreated, map[string]any{"photos": created}) +} + +type uploadError struct { + status int + msg string +} + +func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Part, position int, maxFile int64) (store.Photo, *uploadError) { + head := make([]byte, 16) + n, err := io.ReadFull(part, head) + if err != nil && err != io.ErrUnexpectedEOF { + return store.Photo{}, &uploadError{http.StatusBadRequest, "Could not read file"} + } + head = head[:n] + contentType, ext, ok, reason := imaging.Sniff(head) + if !ok { + return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType, reason} + } + if contentType == "image/heic" && imaging.DetectHeicConverter(s.cfg.HeicConverter) == nil { + return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType, + "HEIC uploads need an image converter on the server (install libvips-tools); please upload JPEG instead"} + } + + // Spool to a temp file to learn the size before handing to storage + // (S3 wants a length; local rename wants a file anyway). + tmp, err := os.CreateTemp(s.cfg.StoragePath, ".incoming-*") + if err != nil { + log.Printf("Error: %v", err) + return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} + } + defer os.Remove(tmp.Name()) + defer tmp.Close() + + size, err := io.Copy(tmp, io.MultiReader(bytes.NewReader(head), io.LimitReader(part, maxFile+1))) + if err != nil { + log.Printf("Error: %v", err) + return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} + } + if size > maxFile { + return store.Photo{}, &uploadError{http.StatusRequestEntityTooLarge, + fmt.Sprintf("File exceeds the %d MB limit", s.cfg.MaxUploadMB)} + } + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + log.Printf("Error: %v", err) + return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} + } + + photoID := newID() + key := fmt.Sprintf("galleries/%s/orig/%s%s", g.ID, photoID, ext) + if err := s.storage.Put(r.Context(), key, tmp, size, contentType); err != nil { + log.Printf("Error: %v", err) + return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} + } + + now := time.Now() + photo := store.Photo{ + ID: photoID, + GalleryID: g.ID, + Position: position, + OriginalKey: key, + OriginalFilename: sanitizeFilename(part.FileName()), + ContentType: contentType, + SizeBytes: size, + Status: "queued", + NextAttemptAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.db.InsertPhoto(r.Context(), photo); err != nil { + s.storage.Delete(r.Context(), key) + log.Printf("Error: %v", err) + return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"} + } + return photo, nil +} + +type reorderBody struct { + PhotoIDs []string `json:"photoIds"` +} + +func (s *Server) reorderPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) { + galleryID := r.PathValue("id") + var body reorderBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON body") + return + } + existing, err := s.db.ListPhotos(r.Context(), galleryID, false) + if err != nil { + writeStoreError(w, err, "") + return + } + if len(existing) == 0 { + writeStoreError(w, store.ErrNotFound, "Gallery not found or empty") + return + } + current := map[string]bool{} + for _, p := range existing { + current[p.ID] = true + } + if len(body.PhotoIDs) != len(existing) { + writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once") + return + } + seen := map[string]bool{} + for _, id := range body.PhotoIDs { + if !current[id] || seen[id] { + writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once") + return + } + seen[id] = true + } + if err := s.db.ReorderPhotos(r.Context(), galleryID, body.PhotoIDs); err != nil { + writeStoreError(w, err, "") + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Order updated"}) +} + +func (s *Server) deletePhoto(w http.ResponseWriter, r *http.Request, _ auth.User) { + p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId")) + if err != nil { + writeStoreError(w, err, "Photo not found") + return + } + if err := s.db.DeletePhoto(r.Context(), p.ID); err != nil { + writeStoreError(w, err, "Photo not found") + return + } + for _, key := range []string{p.OriginalKey, p.ThumbKey, p.PreviewKey} { + if key == "" { + continue + } + if err := s.storage.Delete(r.Context(), key); err != nil { + log.Printf("delete object %s: %v", key, err) + } + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Photo deleted"}) +} + +func (s *Server) retryPhoto(w http.ResponseWriter, r *http.Request, _ auth.User) { + id := r.PathValue("photoId") + if err := s.db.RequeuePhoto(r.Context(), id); err != nil { + writeStoreError(w, err, "Photo not found or not failed") + return + } + s.worker.Nudge() + p, err := s.db.GetPhoto(r.Context(), id) + if err != nil { + writeStoreError(w, err, "Photo not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{"photo": s.photoToJSON(p, "", true)}) +} + +func sanitizeFilename(name string) string { + name = filepath.Base(name) + if name == "." || name == "/" { + return "" + } + if len(name) > 200 { + name = name[len(name)-200:] + } + return name +} diff --git a/photo-api/internal/httpapi/public.go b/photo-api/internal/httpapi/public.go new file mode 100644 index 0000000..98743dc --- /dev/null +++ b/photo-api/internal/httpapi/public.go @@ -0,0 +1,79 @@ +package httpapi + +import ( + "net/http" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +// listPublicGalleries is the public index: only visibility='public' +// galleries ever appear here. +func (s *Server) listPublicGalleries(w http.ResponseWriter, r *http.Request) { + galleries, err := s.db.ListPublicGalleries(r.Context()) + if err != nil { + writeStoreError(w, err, "") + return + } + out := make([]galleryJSON, 0, len(galleries)) + for _, g := range galleries { + out = append(out, s.galleryToJSON(r.Context(), g, "", false)) + } + writeJSON(w, http.StatusOK, map[string]any{"galleries": out}) +} + +// getPublicGallery serves a single gallery to any authorized viewer. +// ?token= carries the share token for link/ticket modes. +func (s *Server) getPublicGallery(w http.ResponseWriter, r *http.Request) { + g, err := s.db.GetGalleryBySlug(r.Context(), r.PathValue("slug")) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + s.respondGalleryView(w, r, g) +} + +// getEventGallery serves the newest gallery linked to an event, backing the +// /events/{slug}/gallery public page. Same access rules as by-slug. +func (s *Server) getEventGallery(w http.ResponseWriter, r *http.Request) { + ev, err := s.db.GetEventSummaryBySlug(r.Context(), r.PathValue("eventSlug")) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + g, err := s.db.GetGalleryByEventID(r.Context(), ev.ID) + if err != nil { + writeStoreError(w, err, "Gallery not found") + return + } + s.respondGalleryView(w, r, g) +} + +func (s *Server) respondGalleryView(w http.ResponseWriter, r *http.Request, g store.Gallery) { + user := s.optionalUser(r) + token := r.URL.Query().Get("token") + if denial := s.authorize(r, g, user, token); denial != nil { + writeError(w, denial.status, denial.msg) + return + } + + // Only pass the token through to file URLs when it was the granting + // credential, so it doesn't leak into public/ticket responses. + urlToken := "" + if token != "" && token == g.ShareToken { + urlToken = token + } + + photos, err := s.db.ListPhotos(r.Context(), g.ID, true) + if err != nil { + writeStoreError(w, err, "") + return + } + out := make([]photoJSON, 0, len(photos)) + for _, p := range photos { + out = append(out, s.photoToJSON(p, urlToken, false)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "gallery": s.galleryToJSON(r.Context(), g, urlToken, false), + "photos": out, + }) +} diff --git a/photo-api/internal/httpapi/respond.go b/photo-api/internal/httpapi/respond.go new file mode 100644 index 0000000..acf2629 --- /dev/null +++ b/photo-api/internal/httpapi/respond.go @@ -0,0 +1,39 @@ +package httpapi + +import ( + "encoding/json" + "log" + "net/http" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + log.Printf("write response: %v", err) + } +} + +// writeError follows the backend's error shape: {"error": string}. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// writeStoreError maps store errors: ErrNotFound -> 404, else 500 with the +// backend's generic message (details go to the log, not the client). +func writeStoreError(w http.ResponseWriter, err error, notFoundMsg string) { + if err == store.ErrNotFound { + writeError(w, http.StatusNotFound, notFoundMsg) + return + } + log.Printf("Error: %v", err) + writeError(w, http.StatusInternalServerError, "Internal Server Error") +} + +func decodeJSON(r *http.Request, dst any) error { + dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) + dec.DisallowUnknownFields() + return dec.Decode(dst) +} diff --git a/photo-api/internal/httpapi/server.go b/photo-api/internal/httpapi/server.go new file mode 100644 index 0000000..065f186 --- /dev/null +++ b/photo-api/internal/httpapi/server.go @@ -0,0 +1,90 @@ +// Package httpapi exposes the photo-api HTTP surface under /api/photos, +// following the backend's route and response conventions (resource-keyed +// success bodies, {"error": string} failures, Bearer auth). +package httpapi + +import ( + "log" + "net/http" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker" +) + +type Server struct { + cfg config.Config + db *store.DB + storage storage.Storage + verifier *auth.Verifier + worker *worker.Worker +} + +func New(cfg config.Config, db *store.DB, st storage.Storage, verifier *auth.Verifier, w *worker.Worker) *Server { + return &Server{cfg: cfg, db: db, storage: st, verifier: verifier, worker: w} +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + health := func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + } + mux.HandleFunc("GET /health", health) + mux.HandleFunc("GET /api/photos/health", health) + + // Admin surface: role admin|organizer only (mirrors /api/media). + mux.HandleFunc("POST /api/photos/galleries", s.requireAdmin(s.createGallery)) + mux.HandleFunc("GET /api/photos/galleries", s.requireAdmin(s.listGalleries)) + mux.HandleFunc("GET /api/photos/galleries/{id}", s.requireAdmin(s.getGallery)) + mux.HandleFunc("PATCH /api/photos/galleries/{id}", s.requireAdmin(s.updateGallery)) + mux.HandleFunc("DELETE /api/photos/galleries/{id}", s.requireAdmin(s.deleteGallery)) + mux.HandleFunc("POST /api/photos/galleries/{id}/photos", s.requireAdmin(s.uploadPhotos)) + mux.HandleFunc("PATCH /api/photos/galleries/{id}/order", s.requireAdmin(s.reorderPhotos)) + mux.HandleFunc("POST /api/photos/galleries/{id}/share-token", s.requireAdmin(s.rotateShareToken)) + mux.HandleFunc("DELETE /api/photos/photos/{photoId}", s.requireAdmin(s.deletePhoto)) + mux.HandleFunc("POST /api/photos/photos/{photoId}/retry", s.requireAdmin(s.retryPhoto)) + + // Viewer surface: optional auth, checked per gallery visibility. + mux.HandleFunc("GET /api/photos/public/galleries", s.listPublicGalleries) + mux.HandleFunc("GET /api/photos/public/galleries/{slug}", s.getPublicGallery) + mux.HandleFunc("GET /api/photos/public/events/{eventSlug}/gallery", s.getEventGallery) + mux.HandleFunc("GET /api/photos/files/{photoId}/{variant}", s.serveFile) + + return s.withCommon(mux) +} + +// withCommon adds request logging and a same-spirit CORS allowance as the +// backend's cors() (origin = FRONTEND_URL); in production nginx serves +// same-origin so this mostly matters in development. +func (s *Server) withCommon(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" && origin == s.cfg.FrontendURL { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Vary", "Origin") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond)) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} diff --git a/photo-api/internal/httpapi/slug.go b/photo-api/internal/httpapi/slug.go new file mode 100644 index 0000000..fb686a1 --- /dev/null +++ b/photo-api/internal/httpapi/slug.go @@ -0,0 +1,72 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "strings" + "unicode" + + "github.com/google/uuid" +) + +// slugify follows the spirit of backend/src/lib/slugify.ts: lowercase, +// ASCII-fold common Spanish characters, dashes for everything else. +func slugify(s string) string { + replacer := strings.NewReplacer( + "á", "a", "é", "e", "í", "i", "ó", "o", "ú", "u", "ü", "u", "ñ", "n", + "Á", "a", "É", "e", "Í", "i", "Ó", "o", "Ú", "u", "Ü", "u", "Ñ", "n") + s = replacer.Replace(strings.ToLower(strings.TrimSpace(s))) + var b strings.Builder + prevDash := true // avoids a leading dash + for _, r := range s { + switch { + case unicode.IsLetter(r) && r < 128, unicode.IsDigit(r): + b.WriteRune(r) + prevDash = false + default: + if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + } + out := strings.Trim(b.String(), "-") + if len(out) > 140 { + out = strings.Trim(out[:140], "-") + } + if out == "" { + out = "gallery" + } + return out +} + +// uniqueSlug appends -2, -3, ... until the slug is free. +func (s *Server) uniqueSlug(ctx context.Context, title string) (string, error) { + base := slugify(title) + slug := base + for i := 2; ; i++ { + exists, err := s.db.SlugExists(ctx, slug) + if err != nil { + return "", err + } + if !exists { + return slug, nil + } + slug = fmt.Sprintf("%s-%d", base, i) + } +} + +func newID() string { + return uuid.NewString() +} + +// newShareToken returns 32 random bytes, base64url (43 chars, no padding). +func newShareToken() string { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + panic(err) // crypto/rand failure is unrecoverable + } + return base64.RawURLEncoding.EncodeToString(buf) +} diff --git a/photo-api/internal/imaging/heic.go b/photo-api/internal/imaging/heic.go new file mode 100644 index 0000000..97ae156 --- /dev/null +++ b/photo-api/internal/imaging/heic.go @@ -0,0 +1,60 @@ +package imaging + +import ( + "fmt" + "os/exec" +) + +// HEIC cannot be decoded in pure Go (HEVC), so conversion shells out to a +// host-installed CLI. The Go binary itself stays cgo-free; the converter is +// a runtime dependency (Debian: libvips-tools or libheif-examples). +type HeicConverter struct { + kind string // "vips" | "heif-convert" | "custom" + path string +} + +// DetectHeicConverter honors an explicit HEIC_CONVERTER command, else +// autodetects vips then heif-convert. Returns nil when none is available; +// uploads of HEIC are then rejected with a clear message. +func DetectHeicConverter(explicit string) *HeicConverter { + if explicit != "" { + if p, err := exec.LookPath(explicit); err == nil { + return &HeicConverter{kind: "custom", path: p} + } + return nil + } + if p, err := exec.LookPath("vips"); err == nil { + return &HeicConverter{kind: "vips", path: p} + } + if p, err := exec.LookPath("heif-convert"); err == nil { + return &HeicConverter{kind: "heif-convert", path: p} + } + return nil +} + +func (h *HeicConverter) Name() string { return h.path } + +// ToJPEG converts src (a .heic file) to a JPEG at dst. +func (h *HeicConverter) ToJPEG(src, dst string) error { + var cmd *exec.Cmd + switch h.kind { + case "vips": + cmd = exec.Command(h.path, "copy", src, dst+"[Q=95]") + case "heif-convert": + cmd = exec.Command(h.path, "-q", "95", src, dst) + default: + // custom converter contract: + cmd = exec.Command(h.path, src, dst) + } + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("heic conversion failed: %v: %s", err, truncate(string(out), 300)) + } + return nil +} + +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} diff --git a/photo-api/internal/imaging/imaging.go b/photo-api/internal/imaging/imaging.go new file mode 100644 index 0000000..15c4162 --- /dev/null +++ b/photo-api/internal/imaging/imaging.go @@ -0,0 +1,159 @@ +// Package imaging produces the two derived variants from an original: +// thumb (512px long edge, JPEG q78) for the grid and preview (2048px long +// edge, JPEG q85) for the lightbox. Originals are never modified. +// Re-encoding strips EXIF (including GPS) from variants; orientation is +// applied to pixels first. +package imaging + +import ( + "fmt" + "image" + "image/jpeg" + "io" + "os" + "time" + + _ "image/gif" + _ "image/png" + + "github.com/rwcarlsen/goexif/exif" + "golang.org/x/image/draw" + _ "golang.org/x/image/webp" +) + +const ( + ThumbLongEdge = 512 + ThumbQuality = 78 + PreviewLongEdge = 2048 + PreviewQuality = 85 +) + +type Result struct { + Width int // of the original, after orientation + Height int + TakenAt time.Time // zero when EXIF has no usable timestamp +} + +// ProcessFile decodes the image at path (JPEG/PNG/GIF/WebP — HEIC must be +// converted to JPEG first), applies EXIF orientation, and writes both +// variants as JPEG to thumbPath and previewPath. +func ProcessFile(path, thumbPath, previewPath string) (Result, error) { + f, err := os.Open(path) + if err != nil { + return Result{}, err + } + orientation, takenAt := readExif(f) + if _, err := f.Seek(0, io.SeekStart); err != nil { + f.Close() + return Result{}, err + } + img, _, err := image.Decode(f) + f.Close() + if err != nil { + return Result{}, fmt.Errorf("decode: %w", err) + } + + img = applyOrientation(img, orientation) + bounds := img.Bounds() + res := Result{Width: bounds.Dx(), Height: bounds.Dy(), TakenAt: takenAt} + + if err := writeVariant(img, previewPath, PreviewLongEdge, PreviewQuality); err != nil { + return res, fmt.Errorf("preview: %w", err) + } + if err := writeVariant(img, thumbPath, ThumbLongEdge, ThumbQuality); err != nil { + return res, fmt.Errorf("thumb: %w", err) + } + return res, nil +} + +func writeVariant(img image.Image, path string, longEdge, quality int) error { + out, err := os.Create(path) + if err != nil { + return err + } + defer out.Close() + return jpeg.Encode(out, resize(img, longEdge), &jpeg.Options{Quality: quality}) +} + +// resize scales so the long edge is at most longEdge, never upscaling. +func resize(img image.Image, longEdge int) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + long := max(w, h) + if long <= longEdge { + return img + } + scale := float64(longEdge) / float64(long) + nw, nh := max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)) + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) + return dst +} + +func readExif(r io.Reader) (orientation int, takenAt time.Time) { + orientation = 1 + x, err := exif.Decode(r) + if err != nil { + return orientation, takenAt + } + if tag, err := x.Get(exif.Orientation); err == nil { + if v, err := tag.Int(0); err == nil && v >= 1 && v <= 8 { + orientation = v + } + } + if t, err := x.DateTime(); err == nil { + takenAt = t + } + return orientation, takenAt +} + +// applyOrientation bakes the EXIF orientation into pixels (values 2-8 per +// the EXIF spec; 1 is identity). +func applyOrientation(img image.Image, orientation int) image.Image { + switch orientation { + case 2: + return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, y }) + case 3: + return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, h - 1 - y }) + case 4: + return transform(img, func(x, y, w, h int) (int, int) { return x, h - 1 - y }) + case 5: + return transformSwap(img, func(x, y, w, h int) (int, int) { return y, x }) + case 6: + return transformSwap(img, func(x, y, w, h int) (int, int) { return y, h - 1 - x }) + case 7: + return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, h - 1 - x }) + case 8: + return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, x }) + default: + return img + } +} + +// transform maps destination (x,y) to source coordinates, same dimensions. +func transform(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + sx, sy := srcAt(x, y, w, h) + dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy)) + } + } + return dst +} + +// transformSwap is for orientations that swap width and height. +func transformSwap(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + dst := image.NewRGBA(image.Rect(0, 0, h, w)) + for y := 0; y < w; y++ { + for x := 0; x < h; x++ { + sx, sy := srcAt(x, y, w, h) + dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy)) + } + } + return dst +} diff --git a/photo-api/internal/imaging/sniff.go b/photo-api/internal/imaging/sniff.go new file mode 100644 index 0000000..847f657 --- /dev/null +++ b/photo-api/internal/imaging/sniff.go @@ -0,0 +1,30 @@ +package imaging + +import "bytes" + +// Sniff identifies an image by magic bytes, ignoring the client-supplied +// filename and Content-Type — same policy as backend/src/routes/media.ts +// detectImageType. Returns the canonical content type and extension, or +// ok=false with a human-readable reason. +func Sniff(head []byte) (contentType, ext string, ok bool, reason string) { + switch { + case len(head) >= 3 && bytes.Equal(head[:3], []byte{0xFF, 0xD8, 0xFF}): + return "image/jpeg", ".jpg", true, "" + case len(head) >= 8 && bytes.Equal(head[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}): + return "image/png", ".png", true, "" + case len(head) >= 6 && (bytes.Equal(head[:6], []byte("GIF87a")) || bytes.Equal(head[:6], []byte("GIF89a"))): + return "image/gif", ".gif", true, "" + case len(head) >= 12 && bytes.Equal(head[:4], []byte("RIFF")) && bytes.Equal(head[8:12], []byte("WEBP")): + return "image/webp", ".webp", true, "" + } + if len(head) >= 12 && bytes.Equal(head[4:8], []byte("ftyp")) { + brand := string(head[8:12]) + switch brand { + case "heic", "heix", "hevc", "hevx", "mif1", "msf1": + return "image/heic", ".heic", true, "" + case "avif", "avis": + return "", "", false, "AVIF is not supported, please upload JPEG, PNG, WebP, GIF or HEIC" + } + } + return "", "", false, "unsupported file type, please upload JPEG, PNG, WebP, GIF or HEIC" +} diff --git a/photo-api/internal/storage/local.go b/photo-api/internal/storage/local.go new file mode 100644 index 0000000..02f9168 --- /dev/null +++ b/photo-api/internal/storage/local.go @@ -0,0 +1,91 @@ +package storage + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// local stores objects under root (STORAGE_PATH, default ./data/photos). +// Deliberately disjoint from backend/uploads; nothing here is ever served +// statically — all reads go through the access-checked files handler. +type local struct { + root string +} + +func newLocal(root string) (*local, error) { + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("create storage dir %s: %w", root, err) + } + return &local{root: root}, nil +} + +// path validates the key stays inside root (keys are generated internally, +// but defense-in-depth costs one Clean call). +func (l *local) path(key string) (string, error) { + clean := filepath.Clean("/" + key) + if strings.Contains(clean, "..") { + return "", fmt.Errorf("invalid key %q", key) + } + return filepath.Join(l.root, clean), nil +} + +func (l *local) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) error { + dst, err := l.path(key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := io.Copy(tmp, r); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), dst) +} + +func (l *local) Open(_ context.Context, key string) (io.ReadCloser, int64, error) { + p, err := l.path(key) + if err != nil { + return nil, 0, err + } + f, err := os.Open(p) + if err != nil { + return nil, 0, err + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, 0, err + } + return f, info.Size(), nil +} + +func (l *local) Delete(_ context.Context, key string) error { + p, err := l.path(key) + if err != nil { + return err + } + err = os.Remove(p) + if os.IsNotExist(err) { + return nil + } + return err +} + +func (l *local) PresignGet(context.Context, string, string, string, time.Duration) (string, error) { + return "", ErrNoPresign +} diff --git a/photo-api/internal/storage/s3.go b/photo-api/internal/storage/s3.go new file mode 100644 index 0000000..ca05239 --- /dev/null +++ b/photo-api/internal/storage/s3.go @@ -0,0 +1,92 @@ +package storage + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" +) + +// s3Store targets AWS S3 or path-style compatibles (Garage/MinIO), same as +// the backend's S3 backend. The bucket is never public: downloads use +// short-lived presigned GETs so visibility rules keep holding on S3. +type s3Store struct { + client *s3.Client + presign *s3.PresignClient + bucket string +} + +func newS3(cfg config.Config) (*s3Store, error) { + awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion(cfg.S3Region), + awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.S3AccessKeyID, cfg.S3SecretKey, "")), + ) + if err != nil { + return nil, fmt.Errorf("s3 config: %w", err) + } + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(cfg.S3Endpoint) + o.UsePathStyle = cfg.S3ForcePathStyle + }) + return &s3Store{ + client: client, + presign: s3.NewPresignClient(client), + bucket: cfg.S3Bucket, + }, nil +} + +func (s *s3Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error { + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: r, + ContentLength: aws.Int64(size), + ContentType: aws.String(contentType), + }) + return err +} + +func (s *s3Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) { + out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, 0, err + } + return out.Body, aws.ToInt64(out.ContentLength), nil +} + +func (s *s3Store) Delete(ctx context.Context, key string) error { + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + return err +} + +func (s *s3Store) PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error) { + in := &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + } + if contentType != "" { + in.ResponseContentType = aws.String(contentType) + } + if downloadFilename != "" { + in.ResponseContentDisposition = aws.String(fmt.Sprintf("attachment; filename=%q", downloadFilename)) + } + req, err := s.presign.PresignGetObject(ctx, in, s3.WithPresignExpires(expiry)) + if err != nil { + return "", err + } + return req.URL, nil +} diff --git a/photo-api/internal/storage/storage.go b/photo-api/internal/storage/storage.go new file mode 100644 index 0000000..37e4b01 --- /dev/null +++ b/photo-api/internal/storage/storage.go @@ -0,0 +1,32 @@ +// Package storage abstracts photo object storage. Selection follows the +// backend's convention (backend/src/lib/storage.ts): S3 when S3_ENDPOINT +// and S3_BUCKET are both set, local disk otherwise. Keys are identical on +// both backends: galleries///.. +package storage + +import ( + "context" + "errors" + "io" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" +) + +// ErrNoPresign is returned by backends that cannot presign (local disk); +// callers then stream the object through the API instead. +var ErrNoPresign = errors.New("presigned URLs not supported") + +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) + Delete(ctx context.Context, key string) error + PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error) +} + +func New(cfg config.Config) (Storage, error) { + if cfg.S3Enabled() { + return newS3(cfg) + } + return newLocal(cfg.StoragePath) +} diff --git a/photo-api/internal/store/access.go b/photo-api/internal/store/access.go new file mode 100644 index 0000000..9db6bd5 --- /dev/null +++ b/photo-api/internal/store/access.go @@ -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 +} diff --git a/photo-api/internal/store/db.go b/photo-api/internal/store/db.go new file mode 100644 index 0000000..cf22ae5 --- /dev/null +++ b/photo-api/internal/store/db.go @@ -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{} +} diff --git a/photo-api/internal/store/galleries.go b/photo-api/internal/store/galleries.go new file mode 100644 index 0000000..f9d5a3f --- /dev/null +++ b/photo-api/internal/store/galleries.go @@ -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 + } +} diff --git a/photo-api/internal/store/migrate.go b/photo-api/internal/store/migrate.go new file mode 100644 index 0000000..74507c1 --- /dev/null +++ b/photo-api/internal/store/migrate.go @@ -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 +} diff --git a/photo-api/internal/store/photos.go b/photo-api/internal/store/photos.go new file mode 100644 index 0000000..cad6391 --- /dev/null +++ b/photo-api/internal/store/photos.go @@ -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< 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) +} diff --git a/photo-api/internal/worker/worker.go b/photo-api/internal/worker/worker.go new file mode 100644 index 0000000..be7e455 --- /dev/null +++ b/photo-api/internal/worker/worker.go @@ -0,0 +1,185 @@ +// Package worker turns queued photos_photos rows into ready ones by +// generating the thumb/preview variants. The rows themselves are the queue +// (status/attempts/next_attempt_at) — no Redis, no jobs table. Claims are +// optimistic UPDATEs so they are safe on both Postgres and SQLite. +package worker + +import ( + "context" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" + "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" +) + +const ( + pollInterval = 10 * time.Second + stuckAfter = 10 * time.Minute +) + +type Worker struct { + db *store.DB + storage storage.Storage + heic *imaging.HeicConverter + tmpDir string + nudge chan struct{} + parallel int +} + +func New(db *store.DB, st storage.Storage, heic *imaging.HeicConverter, tmpDir string, parallel int) *Worker { + if parallel < 1 { + parallel = 1 + } + return &Worker{ + db: db, + storage: st, + heic: heic, + tmpDir: tmpDir, + nudge: make(chan struct{}, 1), + parallel: parallel, + } +} + +// Nudge wakes the worker after an upload so the poll interval is only a +// fallback. +func (w *Worker) Nudge() { + select { + case w.nudge <- struct{}{}: + default: + } +} + +func (w *Worker) Run(ctx context.Context) { + if n, err := w.db.RecoverStuckProcessing(ctx, stuckAfter); err != nil { + log.Printf("worker: recover stuck: %v", err) + } else if n > 0 { + log.Printf("worker: requeued %d photos stuck in processing", n) + } + for i := 0; i < w.parallel; i++ { + go w.loop(ctx) + } +} + +func (w *Worker) loop(ctx context.Context) { + for { + worked := w.drain(ctx) + if ctx.Err() != nil { + return + } + if worked { + continue + } + select { + case <-ctx.Done(): + return + case <-w.nudge: + case <-time.After(pollInterval): + } + } +} + +// drain processes until the queue is empty; returns whether anything ran. +func (w *Worker) drain(ctx context.Context) bool { + worked := false + for ctx.Err() == nil { + photo, err := w.db.ClaimNextPhoto(ctx) + if err == store.ErrNotFound { + return worked + } + if err != nil { + log.Printf("worker: claim: %v", err) + return worked + } + worked = true + if err := w.process(ctx, photo); err != nil { + log.Printf("worker: photo %s attempt %d failed: %v", photo.ID, photo.Attempts, err) + if dberr := w.db.MarkPhotoFailed(ctx, photo.ID, photo.Attempts, err); dberr != nil { + log.Printf("worker: mark failed: %v", dberr) + } + } + } + return worked +} + +func (w *Worker) process(ctx context.Context, p store.Photo) error { + work, err := os.MkdirTemp(w.tmpDir, "photo-*") + if err != nil { + return err + } + defer os.RemoveAll(work) + + // 1. Fetch the original to a local file. + src := filepath.Join(work, "original") + if err := w.download(ctx, p.OriginalKey, src); err != nil { + return fmt.Errorf("fetch original: %w", err) + } + + // 2. HEIC → JPEG via the external converter before the pure-Go pipeline. + if p.ContentType == "image/heic" { + if w.heic == nil { + return fmt.Errorf("HEIC converter not installed on this host (install libvips-tools or libheif-examples)") + } + converted := filepath.Join(work, "converted.jpg") + if err := w.heic.ToJPEG(src, converted); err != nil { + return err + } + src = converted + } + + // 3. Decode, orient, resize, encode. + thumbPath := filepath.Join(work, "thumb.jpg") + previewPath := filepath.Join(work, "preview.jpg") + res, err := imaging.ProcessFile(src, thumbPath, previewPath) + if err != nil { + return err + } + + // 4. Store variants next to the original's key. + base := strings.TrimSuffix(filepath.Base(p.OriginalKey), filepath.Ext(p.OriginalKey)) + prefix := fmt.Sprintf("galleries/%s", p.GalleryID) + thumbKey := fmt.Sprintf("%s/thumb/%s.jpg", prefix, base) + previewKey := fmt.Sprintf("%s/preview/%s.jpg", prefix, base) + if err := w.upload(ctx, thumbKey, thumbPath); err != nil { + return fmt.Errorf("store thumb: %w", err) + } + if err := w.upload(ctx, previewKey, previewPath); err != nil { + return fmt.Errorf("store preview: %w", err) + } + + return w.db.MarkPhotoReady(ctx, p.ID, thumbKey, previewKey, res.Width, res.Height, res.TakenAt) +} + +func (w *Worker) download(ctx context.Context, key, dst string) error { + r, _, err := w.storage.Open(ctx, key) + if err != nil { + return err + } + defer r.Close() + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, r) + return err +} + +func (w *Worker) upload(ctx context.Context, key, src string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return err + } + return w.storage.Put(ctx, key, f, info.Size(), "image/jpeg") +} diff --git a/photo-api/migrations/0001_init.pg.sql b/photo-api/migrations/0001_init.pg.sql new file mode 100644 index 0000000..4b0fc97 --- /dev/null +++ b/photo-api/migrations/0001_init.pg.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS photos_galleries ( + id uuid PRIMARY KEY, + slug varchar(160) NOT NULL UNIQUE, + title varchar(200) NOT NULL, + title_es varchar(200), + description text, + description_es text, + event_id uuid, + visibility varchar(10) NOT NULL DEFAULT 'private' + CHECK (visibility IN ('public','private','link','ticket')), + share_token varchar(64) NOT NULL UNIQUE, + cover_photo_id uuid, + created_by uuid, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS photos_galleries_event_idx ON photos_galleries (event_id); + +CREATE INDEX IF NOT EXISTS photos_galleries_visibility_idx ON photos_galleries (visibility); + +CREATE TABLE IF NOT EXISTS photos_photos ( + id uuid PRIMARY KEY, + gallery_id uuid NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + original_key text NOT NULL, + original_filename text, + content_type varchar(50) NOT NULL, + size_bytes bigint NOT NULL, + width integer, + height integer, + thumb_key text, + preview_key text, + taken_at timestamptz, + 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, + last_error text, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position); + +CREATE INDEX IF NOT EXISTS photos_photos_queue_idx ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed'); diff --git a/photo-api/migrations/0001_init.sqlite.sql b/photo-api/migrations/0001_init.sqlite.sql new file mode 100644 index 0000000..07a2bb7 --- /dev/null +++ b/photo-api/migrations/0001_init.sqlite.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS photos_galleries ( + id text PRIMARY KEY, + slug text NOT NULL UNIQUE, + title text NOT NULL, + title_es text, + description text, + description_es text, + event_id text, + visibility text NOT NULL DEFAULT 'private' + CHECK (visibility IN ('public','private','link','ticket')), + share_token text NOT NULL UNIQUE, + cover_photo_id text, + created_by text, + created_at text NOT NULL, + updated_at text NOT NULL +); + +CREATE INDEX IF NOT EXISTS photos_galleries_event_idx ON photos_galleries (event_id); + +CREATE INDEX IF NOT EXISTS photos_galleries_visibility_idx ON photos_galleries (visibility); + +CREATE TABLE IF NOT EXISTS photos_photos ( + id text PRIMARY KEY, + gallery_id text NOT NULL REFERENCES photos_galleries(id) ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + original_key text NOT NULL, + original_filename text, + content_type text NOT NULL, + size_bytes integer NOT NULL, + width integer, + height integer, + thumb_key text, + preview_key text, + taken_at text, + status text NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued','processing','ready','failed')), + attempts integer NOT NULL DEFAULT 0, + next_attempt_at text NOT NULL, + last_error text, + created_at text NOT NULL, + updated_at text NOT NULL +); + +CREATE INDEX IF NOT EXISTS photos_photos_gallery_pos_idx ON photos_photos (gallery_id, position); + +CREATE INDEX IF NOT EXISTS photos_photos_queue_idx ON photos_photos (status, next_attempt_at) WHERE status IN ('queued','failed'); diff --git a/photo-api/migrations/embed.go b/photo-api/migrations/embed.go new file mode 100644 index 0000000..a610564 --- /dev/null +++ b/photo-api/migrations/embed.go @@ -0,0 +1,8 @@ +// Package migrations embeds the dual-dialect SQL migrations owned by +// photo-api. Drizzle (backend) never sees these tables. +package migrations + +import "embed" + +//go:embed *.sql +var FS embed.FS diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b2b2848 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,763 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + next: + specifier: ^16.2.10 + version: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + devDependencies: + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + +packages: + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@next/env@16.2.10': + resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} + + '@next/swc-darwin-arm64@16.2.10': + resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.10': + resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.10': + resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.2.10': + resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.2.10': + resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.2.10': + resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.2.10': + resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.10': + resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + caniuse-lite@1.0.30001805: + resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concurrently@8.2.2: + resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} + engines: {node: ^14.13.0 || >=16.0.0} + hasBin: true + + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@16.2.10: + resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + spawn-command@0.0.2: + resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + +snapshots: + + '@babel/runtime@7.29.7': {} + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@next/env@16.2.10': {} + + '@next/swc-darwin-arm64@16.2.10': + optional: true + + '@next/swc-darwin-x64@16.2.10': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.10': + optional: true + + '@next/swc-linux-arm64-musl@16.2.10': + optional: true + + '@next/swc-linux-x64-gnu@16.2.10': + optional: true + + '@next/swc-linux-x64-musl@16.2.10': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.10': + optional: true + + '@next/swc-win32-x64-msvc@16.2.10': + optional: true + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + baseline-browser-mapping@2.10.43: {} + + caniuse-lite@1.0.30001805: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concurrently@8.2.2: + dependencies: + chalk: 4.1.2 + date-fns: 2.30.0 + lodash: 4.18.1 + rxjs: 7.8.2 + shell-quote: 1.10.0 + spawn-command: 0.0.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.3 + + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.29.7 + + detect-libc@2.1.2: + optional: true + + emoji-regex@8.0.0: {} + + escalade@3.2.0: {} + + get-caller-file@2.0.5: {} + + has-flag@4.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + lodash@4.18.1: {} + + nanoid@3.3.16: {} + + next@16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@next/env': 16.2.10 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001805 + postcss: 8.4.31 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + styled-jsx: 5.1.6(react@19.2.7) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.10 + '@next/swc-darwin-x64': 16.2.10 + '@next/swc-linux-arm64-gnu': 16.2.10 + '@next/swc-linux-arm64-musl': 16.2.10 + '@next/swc-linux-x64-gnu': 16.2.10 + '@next/swc-linux-x64-musl': 16.2.10 + '@next/swc-win32-arm64-msvc': 16.2.10 + '@next/swc-win32-x64-msvc': 16.2.10 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + picocolors@1.1.1: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + + require-directory@2.1.1: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + scheduler@0.27.0: {} + + semver@7.8.5: + optional: true + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shell-quote@1.10.0: {} + + source-map-js@1.2.1: {} + + spawn-command@0.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + styled-jsx@5.1.6(react@19.2.7): + dependencies: + client-only: 0.0.1 + react: 19.2.7 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 -- 2.54.0 From 71c277045b92d07e14fa1a21aa8e8ff5bfd8049f Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 26 Jul 2026 04:58:40 +0000 Subject: [PATCH 2/4] Centralize seat capacity accounting and the payment provider registry. Replace manualProviders.ts with a paymentProviders.ts registry (automatic vs manual settlement) and move all seat counting into capacity.ts as the single source of truth: only paid/checked-in tickets and pending_approval payments hold a seat, so abandoned checkouts never block sales. Admins can now knowingly approve a payment over capacity (allowOverCapacity), with the booking and admin UIs updated to match. Co-Authored-By: Claude Fable 5 --- backend/src/lib/bookingCleanup.ts | 2 +- backend/src/lib/capacity.ts | 70 +++++++ backend/src/lib/holdRecovery.ts | 82 +++----- backend/src/lib/holdSweep.ts | 48 ++--- backend/src/lib/manualProviders.ts | 7 - backend/src/lib/paymentProviders.ts | 37 ++++ backend/src/routes/admin.ts | 34 +++- backend/src/routes/events.ts | 87 ++++---- backend/src/routes/payments.ts | 88 ++++---- backend/src/routes/tickets.ts | 41 +--- .../src/app/(public)/book/[eventId]/page.tsx | 32 ++- .../events/[id]/EventDetailClient.tsx | 9 +- .../src/app/(public)/events/[id]/page.tsx | 5 +- frontend/src/app/admin/events/page.tsx | 16 +- frontend/src/app/admin/page.tsx | 53 +++-- frontend/src/app/admin/payments/page.tsx | 192 +++++++++++++++--- frontend/src/lib/api/client.ts | 7 +- frontend/src/lib/api/payments.ts | 12 +- frontend/src/lib/api/types.ts | 8 +- frontend/src/lib/utils.ts | 30 +++ 20 files changed, 570 insertions(+), 290 deletions(-) create mode 100644 backend/src/lib/capacity.ts delete mode 100644 backend/src/lib/manualProviders.ts create mode 100644 backend/src/lib/paymentProviders.ts diff --git a/backend/src/lib/bookingCleanup.ts b/backend/src/lib/bookingCleanup.ts index 2e331f1..21907f4 100644 --- a/backend/src/lib/bookingCleanup.ts +++ b/backend/src/lib/bookingCleanup.ts @@ -18,7 +18,7 @@ import { and, eq, lt, inArray, notInArray } from 'drizzle-orm'; import { db, dbAll, tickets, payments } from '../db/index.js'; import { getNow, toDbDate } from './utils.js'; import { getLock } from './stores/lock.js'; -import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js'; +import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js'; function getTtlMs(): number { const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10); diff --git a/backend/src/lib/capacity.ts b/backend/src/lib/capacity.ts new file mode 100644 index 0000000..5c3e69b --- /dev/null +++ b/backend/src/lib/capacity.ts @@ -0,0 +1,70 @@ +// Single source of truth for event seat accounting. +// +// A seat is held by a booking the moment the money is real or claimed to be: +// - ticket 'confirmed' or 'checked_in' (paid), or +// - ticket 'pending' whose payment is 'pending_approval' (customer clicked +// "I've paid" and is waiting for admin verification). +// +// A bare 'pending' payment — an opened checkout that was never paid nor claimed, +// on any provider — holds NO seat, so abandoned bookings can never block sales. +// The accepted trade-off is a small oversell window when several people book the +// last seats and all later pay/claim; admin approval is the backstop and may +// knowingly approve over capacity (see routes/payments.ts). +// +// 'pending_approval' is a payment status (the ticket row stays 'pending'), so +// every capacity count joins tickets to payments. There is exactly one payment +// row per ticket (created together in routes/tickets.ts). +// +// Every place that counts seats — booking creation, public availability, +// hold recovery, admin dashboards — must go through these builders so the +// formula cannot diverge between surfaces. + +import { sql, and, eq, inArray } from 'drizzle-orm'; +import { tickets, payments } from '../db/index.js'; + +// COALESCE keeps the predicate two-valued under the LEFT JOIN (a ticket with no +// payment row must count as "not holding" — NULL would poison NOT ...). +export const seatHoldingSql = sql`(${(tickets as any).status} IN ('confirmed', 'checked_in') OR (${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval'))`; + +/** + * Query: number of seats currently held for an event. + * `executor` is the db, or a transaction (sync sqlite tx: finish with `.get()`; + * async pg tx / plain db: await via dbGet). + */ +export function seatHolderCountQuery(executor: any, eventId: string) { + return executor + .select({ count: sql`count(distinct ${(tickets as any).id})` }) + .from(tickets) + .leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id)) + .where(and(eq((tickets as any).eventId, eventId), seatHoldingSql)); +} + +/** + * Query: how many of the given tickets do NOT currently hold a seat (and so + * would need fresh capacity if promoted to a seat-holding state). + */ +export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) { + return executor + .select({ count: sql`count(distinct ${(tickets as any).id})` }) + .from(tickets) + .leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id)) + .where(and(inArray((tickets as any).id, ticketIds), sql`NOT ${seatHoldingSql}`)); +} + +/** + * Query: per-event breakdown of paid vs claimed seats, grouped by event. + * paidCount = confirmed + checked_in; claimedCount = pending_approval-held. + * Pass `eventId` to restrict to one event (still returns a grouped row). + */ +export function eventSeatBreakdownQuery(executor: any, eventId?: string) { + const query = executor + .select({ + eventId: (tickets as any).eventId, + paidCount: sql`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`, + claimedCount: sql`sum(case when ${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval' then 1 else 0 end)`, + }) + .from(tickets) + .leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id)); + return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query) + .groupBy((tickets as any).eventId); +} diff --git a/backend/src/lib/holdRecovery.ts b/backend/src/lib/holdRecovery.ts index d80adb5..1983430 100644 --- a/backend/src/lib/holdRecovery.ts +++ b/backend/src/lib/holdRecovery.ts @@ -1,19 +1,22 @@ -// Shared capacity-checked recovery for released bookings. +// Shared capacity-checked recovery for bookings that don't currently hold a seat. // -// When a booking is put on hold (or failed/cancelled), its ticket(s) drop out of the -// capacity-counting statuses ('pending', 'confirmed', 'checked_in'), releasing the seat. -// Recovering such a booking (user "I've paid" again, or an admin reactivating / marking -// it paid / reopening a failed payment) must atomically re-check that the event still has -// room before re-reserving the seat, exactly like the original booking-creation flow in -// routes/tickets.ts. +// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in +// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those +// states — user clicking "I've paid", an admin approving/reactivating a payment — +// must atomically re-check that the event still has room, exactly like the original +// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending' +// (e.g. reopening a failed payment) claims no seat and skips the check. // -// Callers choose which ticket statuses are eligible to be re-reserved via -// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list — and -// tickets that already hold a seat — are left untouched and don't consume capacity. +// Callers choose which ticket statuses are eligible to be flipped via +// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are +// left untouched. Tickets that already hold a seat cost no new capacity. +// `options.skipCapacityCheck` lets an admin knowingly approve over capacity — +// the UI warns first (routes/payments.ts /approve with allowOverCapacity). -import { eq, and, inArray, sql } from 'drizzle-orm'; +import { eq, and, inArray } from 'drizzle-orm'; import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js'; import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js'; +import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js'; export class HoldCapacityError extends Error { constructor(public available: number) { @@ -26,6 +29,8 @@ interface ReserveOptions { extraPaymentFields?: Record; /** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */ fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>; + /** Admin override: reserve even when it puts the event over capacity. */ + skipCapacityCheck?: boolean; } /** @@ -34,7 +39,8 @@ interface ReserveOptions { * Only tickets whose current status is in `fromTicketStatuses` are flipped. * Capacity is asserted against the number of those tickets that don't already * hold a seat, so re-reserving tickets that are already seated is a no-op. - * Throws HoldCapacityError if the event no longer has room. + * Throws HoldCapacityError if the event no longer has room (unless the target + * state holds no seat, or skipCapacityCheck is set). */ export async function reserveOnHoldBooking( eventId: string, @@ -46,6 +52,10 @@ export async function reserveOnHoldBooking( if (ticketIds.length === 0) return; const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold']; + // Bare 'pending' payments hold no seat, so moving a booking back to 'pending' + // consumes no capacity and needs no check. + const targetHoldsSeat = targetPaymentStatus !== 'pending' || targetTicketStatus === 'confirmed'; + const checkCapacity = targetHoldsSeat && !options.skipCapacityCheck; const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, eventId)) @@ -66,7 +76,7 @@ export async function reserveOnHoldBooking( } // `needed` is how many of these tickets don't currently hold a seat and so must - // be found new capacity; tickets already in a seat-holding status cost nothing. + // be found new capacity; tickets already in a seat-holding state cost nothing. const assertCapacity = (reserved: number, needed: number) => { if (needed <= 0) return; if (isEventSoldOut(event.capacity, reserved)) { @@ -80,23 +90,11 @@ export async function reserveOnHoldBooking( if (isSqlite()) { (db as any).transaction((tx: any) => { - const countRow = tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where(and( - eq((tickets as any).eventId, eventId), - sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` - )) - .get(); - const neededRow = tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where(and( - inArray((tickets as any).id, ticketIds), - sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')` - )) - .get(); - assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); + if (checkCapacity) { + const countRow = seatHolderCountQuery(tx, eventId).get(); + const neededRow = unseatedTicketCountQuery(tx, ticketIds).get(); + assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); + } tx.update(tickets) .set({ status: targetTicketStatus }) @@ -113,25 +111,11 @@ export async function reserveOnHoldBooking( }); } else { await (db as any).transaction(async (tx: any) => { - const countRow = await dbGet( - tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where(and( - eq((tickets as any).eventId, eventId), - sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` - )) - ); - const neededRow = await dbGet( - tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where(and( - inArray((tickets as any).id, ticketIds), - sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')` - )) - ); - assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); + if (checkCapacity) { + const countRow = await dbGet(seatHolderCountQuery(tx, eventId)); + const neededRow = await dbGet(unseatedTicketCountQuery(tx, ticketIds)); + assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); + } await tx.update(tickets) .set({ status: targetTicketStatus }) diff --git a/backend/src/lib/holdSweep.ts b/backend/src/lib/holdSweep.ts index 912003c..41bd115 100644 --- a/backend/src/lib/holdSweep.ts +++ b/backend/src/lib/holdSweep.ts @@ -1,23 +1,24 @@ -// Auto-hold stale manual-payment bookings. +// Auto-hold stale unsettled manual-payment bookings. // -// This job releases the seat held by an abandoned manual-payment booking (bank -// transfer / TPago / cash) after HOLD_THRESHOLD_HOURS. It covers two states, both of -// which keep a seat reserved while awaiting a human: -// - 'pending_approval': the user clicked "I've paid" and is waiting for an admin. -// - 'pending' on a manual provider (see MANUAL_PAYMENT_PROVIDERS): the booking was -// never settled (these are exempt from the 30-min auto-fail in bookingCleanup.ts, -// so this is their only seat-release path). -// In either case the payment (and its ticket) is silently moved to 'on_hold', which -// drops it out of the capacity-counting statuses ('pending', 'confirmed', 'checked_in') -// and so releases the seat back to the event. The user receives no notification — they -// can recover via "I've paid" again, and an admin can reactivate or mark it paid -// directly, both re-checking capacity. +// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash) +// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e. +// the customer never clicked "I've paid" and no admin settled them. These are exempt +// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in +// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps +// dead checkouts out of the admin's pending queues. +// +// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a +// claimed payment keeps its seat until an admin approves or rejects it — the admin +// UI surfaces aging claims instead of silently releasing them. +// +// The user receives no notification — they can recover via "I've paid", and an +// admin can approve/reactivate directly; every recovery path re-checks capacity. -import { and, or, eq, lt, inArray } from 'drizzle-orm'; +import { and, eq, lt, inArray } from 'drizzle-orm'; import { db, dbAll, tickets, payments } from '../db/index.js'; import { getNow, toDbDate } from './utils.js'; import { getLock } from './stores/lock.js'; -import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js'; +import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js'; function getThresholdMs(): number { const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10); @@ -25,9 +26,9 @@ function getThresholdMs(): number { } /** - * Move stale awaiting-verification payments (and their tickets) to 'on_hold'. - * Covers 'pending_approval' payments and 'pending' payments on manual providers. - * Returns the number of payments put on hold. + * Move stale unsettled manual payments (and their tickets) to 'on_hold'. + * Covers only bare 'pending' payments on manual providers; 'pending_approval' + * is never swept. Returns the number of payments put on hold. */ export async function sweepStaleApprovals(): Promise { const cutoff = toDbDate(new Date(Date.now() - getThresholdMs())); @@ -40,13 +41,8 @@ export async function sweepStaleApprovals(): Promise { }) .from(payments) .where(and( - or( - eq((payments as any).status, 'pending_approval'), - and( - eq((payments as any).status, 'pending'), - inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]) - ) - ), + eq((payments as any).status, 'pending'), + inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]), lt((payments as any).createdAt, cutoff) )) ); @@ -72,7 +68,7 @@ export async function sweepStaleApprovals(): Promise { )); } - console.log(`[HoldSweep] Put ${stale.length} stale awaiting-verification payment(s) on hold.`); + console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`); return stale.length; } diff --git a/backend/src/lib/manualProviders.ts b/backend/src/lib/manualProviders.ts deleted file mode 100644 index 5252755..0000000 --- a/backend/src/lib/manualProviders.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Payment providers that require a human to verify the money arrived. -// -// These methods (bank transfer / TPago / cash) are never auto-confirmed and — -// crucially — are never auto-failed by the stale-booking cleanup: an admin settles -// them by hand. Note this is broader than the set of methods that expose an online -// "I've paid" step (bank transfer / TPago only); cash is settled at the door. -export const MANUAL_PAYMENT_PROVIDERS = ['bank_transfer', 'tpago', 'cash'] as const; diff --git a/backend/src/lib/paymentProviders.ts b/backend/src/lib/paymentProviders.ts new file mode 100644 index 0000000..83ad086 --- /dev/null +++ b/backend/src/lib/paymentProviders.ts @@ -0,0 +1,37 @@ +// Payment provider registry. +// +// Every provider is either: +// - 'automatic': the gateway itself confirms the payment (webhook/invoice +// settlement) and the booking is auto-approved on success. No admin involved. +// Currently Lightning; future online gateways (e.g. Stripe) go here. +// - 'manual': a human must verify the money arrived (TPago, bank transfer, +// card handled offline, cash at the door). These are never auto-confirmed +// and never auto-failed; an admin settles them by hand. Bank transfer and +// TPago additionally expose an online "I've paid" step that moves the +// payment to 'pending_approval'. +// +// Capacity note (see lib/capacity.ts): only paid/checked-in tickets and +// 'pending_approval' payments hold a seat. A bare 'pending' payment — of either +// kind — holds no seat, so an abandoned checkout can never block sales. + +export type PaymentProviderKind = 'automatic' | 'manual'; + +export const PAYMENT_PROVIDERS: Record = { + lightning: { kind: 'automatic' }, + tpago: { kind: 'manual' }, + bank_transfer: { kind: 'manual' }, + card: { kind: 'manual' }, + cash: { kind: 'manual' }, +}; + +export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter( + (p) => PAYMENT_PROVIDERS[p].kind === 'manual' +); + +export function isManualProvider(provider: string): boolean { + return PAYMENT_PROVIDERS[provider]?.kind === 'manual'; +} + +export function isAutomaticProvider(provider: string): boolean { + return PAYMENT_PROVIDERS[provider]?.kind === 'automatic'; +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index c7a355f..609713f 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -3,6 +3,7 @@ import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubs import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { getNow } from '../lib/utils.js'; +import { eventSeatBreakdownQuery } from '../lib/capacity.js'; const adminRouter = new Hono(); @@ -20,8 +21,9 @@ const csvEscape = (value: string) => { adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => { const now = getNow(); - // Get upcoming events - const upcomingEvents = await dbAll( + // Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts) + // so the dashboard's capacity alerts reflect real availability. + const upcomingEventsRaw = await dbAll( (db as any) .select() .from(events) @@ -34,6 +36,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => .orderBy((events as any).startDatetime) .limit(5) ); + + const seatRows = await dbAll(eventSeatBreakdownQuery(db)); + const seatsByEvent = new Map(); + for (const row of seatRows) { + seatsByEvent.set(row.eventId, { + paid: Number(row.paidCount) || 0, + claimed: Number(row.claimedCount) || 0, + }); + } + const upcomingEvents = upcomingEventsRaw.map((event: any) => { + const counts = seatsByEvent.get(event.id) || { paid: 0, claimed: 0 }; + return { + ...event, + bookedCount: counts.paid, + claimedCount: counts.claimed, + availableSeats: Math.max(0, (event.capacity || 0) - counts.paid - counts.claimed), + }; + }); // Get recent tickets const recentTickets = await dbAll( @@ -70,6 +90,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => .where(eq((tickets as any).status, 'confirmed')) ); + // 'pending' = checkout opened, nothing paid or claimed (informational); + // 'pending_approval' = customer says they paid, needs admin verification (actionable). const pendingPayments = await dbGet( (db as any) .select({ count: sql`count(*)` }) @@ -77,6 +99,13 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => .where(eq((payments as any).status, 'pending')) ); + const awaitingApprovalPayments = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(payments) + .where(eq((payments as any).status, 'pending_approval')) + ); + const onHoldPayments = await dbGet( (db as any) .select({ count: sql`count(*)` }) @@ -115,6 +144,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => totalTickets: totalTickets?.count || 0, confirmedTickets: confirmedTickets?.count || 0, pendingPayments: pendingPayments?.count || 0, + awaitingApprovalPayments: awaitingApprovalPayments?.count || 0, onHoldPayments: onHoldPayments?.count || 0, totalRevenue, newContacts: newContacts?.count || 0, diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 07fe638..c032b84 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js'; import { slugify, uniqueSlug } from '../lib/slugify.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; +import { eventSeatBreakdownQuery } from '../lib/capacity.js'; interface UserContext { id: string; @@ -201,27 +202,28 @@ eventsRouter.get('/', async (c) => { const result = await dbAll(query.orderBy(desc((events as any).startDatetime))); - // Single grouped query for booked counts across all events (avoids N+1: previously - // this ran one COUNT query per event). - const countRows = await dbAll( - (db as any) - .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) - .from(tickets) - .where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`) - .groupBy((tickets as any).eventId) - ); - const countByEvent = new Map(); + // Single grouped query for seat counts across all events (avoids N+1: previously + // this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in); + // claimedCount = "I've paid" claims awaiting admin verification. Both hold seats, + // so availableSeats subtracts them together — the same formula the booking-creation + // capacity check enforces (lib/capacity.ts). + const countRows = await dbAll(eventSeatBreakdownQuery(db)); + const countByEvent = new Map(); for (const row of countRows) { - countByEvent.set(row.eventId, Number(row.count) || 0); + countByEvent.set(row.eventId, { + paid: Number(row.paidCount) || 0, + claimed: Number(row.claimedCount) || 0, + }); } const eventsWithCounts = result.map((event: any) => { const normalized = normalizeEvent(event); - const bookedCount = countByEvent.get(event.id) || 0; + const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 }; return { ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), + bookedCount: counts.paid, + claimedCount: counts.claimed, + availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed), }; }); @@ -246,27 +248,14 @@ eventsRouter.get('/:id', async (c) => { } } - // Count confirmed AND checked_in tickets (checked_in were previously confirmed) - // This ensures check-in doesn't affect capacity/spots_left - const ticketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` - ) - ) - ); - const normalized = normalizeEvent(event); - const bookedCount = ticketCount?.count || 0; + const counts = await getEventSeatCounts(event.id); return c.json({ event: { ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), + bookedCount: counts.paid, + claimedCount: counts.claimed, + availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed), }, }); }); @@ -278,20 +267,14 @@ async function getSiteTimezone(): Promise { return settings?.timezone || 'America/Asuncion'; } -// Helper function to get ticket count for an event -async function getEventTicketCount(eventId: string): Promise { - const ticketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, eventId), - sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` - ) - ) - ); - return ticketCount?.count || 0; +// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat +// counts for one event — see lib/capacity.ts for the seat-holding rule. +async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> { + const row = await dbGet(eventSeatBreakdownQuery(db, eventId)); + return { + paid: Number(row?.paidCount) || 0, + claimed: Number(row?.claimedCount) || 0, + }; } // Get the earliest upcoming published event with ticket counts (ignores featured promotion) @@ -315,12 +298,13 @@ async function getNextChronologicalUpcoming(): Promise { return null; } - const bookedCount = await getEventTicketCount(event.id); + const counts = await getEventSeatCounts(event.id); const normalized = normalizeEvent(event); return { ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), + bookedCount: counts.paid, + claimedCount: counts.claimed, + availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed), }; } @@ -383,13 +367,14 @@ eventsRouter.get('/next/upcoming', async (c) => { // If we have a valid featured event, return it if (featuredEvent) { - const bookedCount = await getEventTicketCount(featuredEvent.id); + const counts = await getEventSeatCounts(featuredEvent.id); const normalized = normalizeEvent(featuredEvent); return c.json({ event: { ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), + bookedCount: counts.paid, + claimedCount: counts.claimed, + availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed), isFeatured: true, }, }); diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index 1914b73..692ad0d 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -19,6 +19,9 @@ const updatePaymentSchema = z.object({ const approvePaymentSchema = z.object({ adminNote: z.string().optional(), sendEmail: z.boolean().optional().default(true), + // Admin override: confirm the booking even when it puts the event over + // capacity. The UI asks for explicit confirmation before sending this. + allowOverCapacity: z.boolean().optional().default(false), }); const rejectPaymentSchema = z.object({ @@ -317,29 +320,28 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json // Approve payment (admin) - specifically for pending_approval payments paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => { const id = c.req.param('id'); - const { adminNote, sendEmail } = c.req.valid('json'); + const { adminNote, sendEmail, allowOverCapacity } = c.req.valid('json'); const user = (c as any).get('user'); - + const payment = await dbGet( (db as any) .select() .from(payments) .where(eq((payments as any).id, id)) ); - + if (!payment) { return c.json({ error: 'Payment not found' }, 404); } - + // Can approve pending, pending_approval, on_hold, or failed payments. // 'failed' covers an admin confirming a payment that was auto-failed or rejected // in error; its tickets are cancelled, so recovery re-checks capacity below. + // Bare 'pending' covers customers who paid but never clicked "I've paid". if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) { return c.json({ error: 'Payment cannot be approved in its current state' }, 400); } - const now = getNow(); - // Get the ticket associated with this payment const ticket = await dbGet( (db as any) @@ -381,55 +383,35 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida } } - if (payment.status === 'on_hold' || payment.status === 'failed') { - // The seat was released when this booking went on hold or failed - re-check - // capacity before confirming it directly. - try { - await reserveOnHoldBooking( - ticket.eventId, - ticketsToConfirm.map((t: any) => t.id), - 'confirmed', - 'paid', - { - paidByAdminId: user.id, - fromTicketStatuses: - payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold'], - } - ); - } catch (err) { - if (err instanceof HoldCapacityError) { - return c.json({ - error: 'This event is now full. Your spot was released after the payment deadline passed.', - }, 400); + // Confirm the booking through the shared capacity-checked reservation. + // Tickets that already hold a seat ('pending_approval' claims) cost no new + // capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do. + // When the event is full, the admin gets a structured over-capacity error and + // may retry with allowOverCapacity to knowingly overbook. + try { + await reserveOnHoldBooking( + ticket.eventId, + ticketsToConfirm.map((t: any) => t.id), + 'confirmed', + 'paid', + { + paidByAdminId: user.id, + fromTicketStatuses: + payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'], + skipCapacityCheck: allowOverCapacity, + ...(adminNote ? { extraPaymentFields: { adminNote } } : {}), } - throw err; - } - if (adminNote) { - await (db as any) - .update(payments) - .set({ adminNote }) - .where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id))); - } - } else { - // Update all payments in the booking to paid - for (const t of ticketsToConfirm) { - await (db as any) - .update(payments) - .set({ - status: 'paid', - paidAt: now, - paidByAdminId: user.id, - adminNote: adminNote || payment.adminNote, - updatedAt: now, - }) - .where(eq((payments as any).ticketId, (t as any).id)); - - // Update ticket status to confirmed - await (db as any) - .update(tickets) - .set({ status: 'confirmed' }) - .where(eq((tickets as any).id, (t as any).id)); + ); + } catch (err) { + if (err instanceof HoldCapacityError) { + return c.json({ + error: 'Approving this payment puts the event over capacity.', + code: 'EVENT_OVER_CAPACITY', + availableSeats: err.available, + requestedSeats: ticketsToConfirm.length, + }, 409); } + throw err; } // Send confirmation emails asynchronously (if sendEmail is true, which is the default) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 1ff4610..4c04f92 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -10,6 +10,7 @@ import { rateLimitMiddleware } from '../lib/rateLimit.js'; import emailService from '../lib/email.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js'; +import { seatHolderCountQuery } from '../lib/capacity.js'; const ticketsRouter = new Hono(); @@ -128,21 +129,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { return c.json({ error: 'Selected payment method is not available for this event' }, 400); } - // Check capacity - count pending, confirmed AND checked_in tickets. - // Pending reservations must hold seats to prevent overselling via unpaid bookings - // (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats). + // Check capacity against held seats (paid/checked-in tickets plus claimed + // manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat. const existingTicketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, data.eventId), - sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` - ) - ) + seatHolderCountQuery(db, data.eventId) ); - + const confirmedCount = existingTicketCount?.count || 0; const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount); @@ -230,16 +222,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { try { if (isSqlite()) { (db as any).transaction((tx: any) => { - const countRow = tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, data.eventId), - sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` - ) - ) - .get(); + const countRow = seatHolderCountQuery(tx, data.eventId).get(); const reserved = Number(countRow?.count || 0); if (isEventSoldOut(event.capacity, reserved)) { throw new BookingCapacityError('SOLD_OUT'); @@ -290,17 +273,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { }); } else { await (db as any).transaction(async (tx: any) => { - const countRow = await dbGet( - tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, data.eventId), - sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` - ) - ) - ); + const countRow = await dbGet(seatHolderCountQuery(tx, data.eventId)); const reserved = Number(countRow?.count || 0); if (isEventSoldOut(event.capacity, reserved)) { throw new BookingCapacityError('SOLD_OUT'); diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index e51fff0..a159ed5 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; -import { formatDateLong, formatTime, formatRucDisplay } from '@/lib/utils'; +import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; import { isSafeExternalUrl } from '@/lib/safeRedirect'; import toast from 'react-hot-toast'; import type { @@ -108,16 +108,15 @@ export default function BookingPage() { return; } - const bookedCount = eventRes.event.bookedCount ?? 0; - const capacity = eventRes.event.capacity ?? 0; - const soldOut = bookedCount >= capacity; - if (soldOut) { + // Server-authoritative availability — same formula the booking API + // enforces, so a sold-out event is caught here, not at submit time. + if (isEventSoldOut(eventRes.event)) { toast.error(t('events.details.soldOut')); router.push(`/events/${eventRes.event.slug}`); return; } - const spotsLeft = Math.max(0, capacity - bookedCount); + const spotsLeft = eventSpotsLeft(eventRes.event); setEvent(eventRes.event); // Cap quantity by available spots (never allow requesting more than spotsLeft) setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft))); @@ -366,6 +365,23 @@ export default function BookingPage() { } } catch (error: any) { toast.error(error.message || t('booking.form.errors.bookingFailed')); + // Capacity race on the last seats: refresh availability so the page + // reflects reality (sold-out block / lower quantity cap) instead of the + // stale counts loaded when the form was opened. + const message = String(error?.message || ''); + if (/sold out|seats available/i.test(message)) { + try { + const { event: freshEvent } = await eventsApi.getById(params.eventId as string); + setEvent(freshEvent); + const freshSpots = eventSpotsLeft(freshEvent); + if (freshSpots > 0) { + setTicketQuantity((q) => Math.min(q, freshSpots)); + setAttendees((prev) => prev.slice(0, Math.max(0, freshSpots - 1))); + } + } catch { + // Keep the stale event state if the refresh fails + } + } } finally { setSubmitting(false); } @@ -388,8 +404,8 @@ export default function BookingPage() { return null; } - const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); - const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; + const spotsLeft = eventSpotsLeft(event); + const isSoldOut = isEventSoldOut(event); // Paying step - waiting for Lightning payment (compact design) if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) { diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index fa959fa..ba0eb77 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; -import { formatPrice, formatDateLong, formatTime } from '@/lib/utils'; +import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import ShareButtons from '@/components/ShareButtons'; @@ -43,9 +43,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail .catch(console.error); }, [eventId]); - // Spots left: never negative; sold out when confirmed >= capacity - const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); - const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; + // Server-authoritative availability (paid + claimed seats count; abandoned + // pending bookings don't) — matches the booking API's sold-out check exactly. + const spotsLeft = eventSpotsLeft(event); + const isSoldOut = isEventSoldOut(event); const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft)); useEffect(() => { diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index fd1246c..cec0e03 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -117,7 +117,10 @@ function generateEventJsonLd(event: Event) { '@type': 'Offer', price: event.price, priceCurrency: event.currency, - availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0 + availability: + (typeof event.availableSeats === 'number' + ? event.availableSeats + : Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0 ? 'https://schema.org/InStock' : 'https://schema.org/SoldOut', url: `${siteUrl}/events/${event.slug}`, diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx index 54f547b..1bb1051 100644 --- a/frontend/src/app/admin/events/page.tsx +++ b/frontend/src/app/admin/events/page.tsx @@ -222,7 +222,14 @@ export default function AdminEventsPage() {
{formatDate(event.startDatetime)} - {event.bookedCount || 0} / {event.capacity} + + {(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} + {(event.claimedCount || 0) > 0 && ( + + {event.claimedCount} pending approval + + )} +
{getStatusBadge(event.status)} @@ -332,7 +339,12 @@ export default function AdminEventsPage() {
-

{event.bookedCount || 0} / {event.capacity} spots

+

+ {(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} spots + {(event.claimedCount || 0) > 0 && ( + · {event.claimedCount} pending + )} +

e.stopPropagation()}> diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 3929aa2..58b8def 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -15,7 +15,7 @@ import { UserGroupIcon, ExclamationTriangleIcon, } from '@heroicons/react/24/outline'; -import { parseDate } from '@/lib/utils'; +import { parseDate, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; export default function AdminDashboardPage() { const { t, locale } = useLanguage(); @@ -112,16 +112,15 @@ export default function AdminDashboardPage() {

Alerts

- {/* Low capacity warnings */} + {/* Low capacity warnings (availableSeats accounts for paid + claimed seats) */} {data?.upcomingEvents .filter(event => { - const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0)); - const percentFull = ((event.bookedCount || 0) / event.capacity) * 100; - return percentFull >= 80 && spotsLeft > 0; + const spotsLeft = eventSpotsLeft(event); + return event.capacity > 0 && spotsLeft > 0 && spotsLeft / event.capacity <= 0.2; }) .map(event => { - const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0)); - const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100); + const spotsLeft = eventSpotsLeft(event); + const percentFull = Math.round(((event.capacity - spotsLeft) / event.capacity) * 100); return ( Math.max(0, event.capacity - (event.bookedCount || 0)) === 0) + .filter(event => isEventSoldOut(event)) .map(event => ( ))} - {data && data.stats.pendingPayments > 0 && ( - 0 && ( +
- Pending payments + Payments awaiting verification
- {data.stats.pendingPayments} + {data.stats.awaitingApprovalPayments} + + )} + {/* Informational: opened checkouts that never paid — hold no seats */} + {data && data.stats.pendingPayments > 0 && ( + +
+ + Unpaid started bookings +
+ {data.stats.pendingPayments} )} {data && data.stats.newContacts > 0 && ( @@ -186,10 +199,11 @@ export default function AdminDashboardPage() { )} {/* No alerts */} - {data && - data.stats.pendingPayments === 0 && - data.stats.newContacts === 0 && - !data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && ( + {data && + data.stats.pendingPayments === 0 && + (data.stats.awaitingApprovalPayments ?? 0) === 0 && + data.stats.newContacts === 0 && + !data.upcomingEvents.some(e => e.capacity > 0 && eventSpotsLeft(e) / e.capacity <= 0.2) && (

No alerts at this time

)}
@@ -218,7 +232,12 @@ export default function AdminDashboardPage() {

{formatDate(event.startDatetime)}

- {event.bookedCount || 0}/{event.capacity} + {(event.bookedCount || 0) + (event.claimedCount || 0)}/{event.capacity} + {(event.claimedCount || 0) > 0 && ( + + {event.claimedCount} pending approval + + )} ))} diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx index 40a08fb..1c4e9f7 100644 --- a/frontend/src/app/admin/payments/page.tsx +++ b/frontend/src/app/admin/payments/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api'; +import { isManualProvider } from '@/lib/api/payments'; import { parseDate, formatRucDisplay } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; @@ -36,6 +37,9 @@ export default function AdminPaymentsPage() { const { t, locale } = useLanguage(); const [payments, setPayments] = useState([]); const [pendingApprovalPayments, setPendingApprovalPayments] = useState([]); + // Manual-gateway payments still in bare 'pending': the customer may have paid + // without clicking "I've paid" — approvable directly from the approval tab. + const [unclaimedManualPayments, setUnclaimedManualPayments] = useState([]); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('pending_approval'); @@ -69,17 +73,19 @@ export default function AdminPaymentsPage() { const loadData = async () => { try { setLoading(true); - const [pendingRes, allRes, eventsRes] = await Promise.all([ + const [pendingRes, allRes, unclaimedRes, eventsRes] = await Promise.all([ paymentsApi.getPendingApproval(), - paymentsApi.getAll({ - status: statusFilter || undefined, + paymentsApi.getAll({ + status: statusFilter || undefined, provider: providerFilter || undefined, eventIds: eventFilter.length > 0 ? eventFilter : undefined, }), + paymentsApi.getAll({ status: 'pending' }), eventsApi.getAll(), ]); setPendingApprovalPayments(pendingRes.payments); setPayments(allRes.payments); + setUnclaimedManualPayments(unclaimedRes.payments.filter(p => isManualProvider(p.provider))); setEvents(eventsRes.events); } catch (error) { toast.error('Failed to load payments'); @@ -88,15 +94,35 @@ export default function AdminPaymentsPage() { } }; + // Approve with over-capacity confirmation: the backend rejects an approval + // that would overbook the event unless the admin explicitly allows it. + const approveWithCapacityConfirm = async (id: string, note?: string, email?: boolean) => { + try { + await paymentsApi.approve(id, note, email); + } catch (error: any) { + if (error?.code !== 'EVENT_OVER_CAPACITY') throw error; + const seatsLeft = error?.data?.availableSeats ?? 0; + const requested = error?.data?.requestedSeats ?? 1; + const message = locale === 'es' + ? `El evento está lleno (quedan ${seatsLeft} lugares, esta reserva necesita ${requested}). ¿Aprobar de todas formas y sobrevender?` + : `This event is full (${seatsLeft} seat(s) left, this booking needs ${requested}). Approve anyway and overbook?`; + if (!confirm(message)) return false; + await paymentsApi.approve(id, note, email, true); + } + return true; + }; + const handleApprove = async (payment: PaymentWithDetails) => { setProcessing(true); try { - await paymentsApi.approve(payment.id, noteText, sendEmail); - toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved'); - setSelectedPayment(null); - setNoteText(''); - setSendEmail(true); - loadData(); + const approved = await approveWithCapacityConfirm(payment.id, noteText, sendEmail); + if (approved) { + toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved'); + setSelectedPayment(null); + setNoteText(''); + setSendEmail(true); + loadData(); + } } catch (error: any) { toast.error(error.message || 'Failed to approve payment'); } finally { @@ -140,11 +166,13 @@ export default function AdminPaymentsPage() { const handleConfirmPayment = async (id: string) => { try { - await paymentsApi.approve(id); - toast.success('Payment confirmed'); - loadData(); - } catch (error) { - toast.error('Failed to confirm payment'); + const approved = await approveWithCapacityConfirm(id); + if (approved) { + toast.success('Payment confirmed'); + loadData(); + } + } catch (error: any) { + toast.error(error.message || 'Failed to confirm payment'); } }; @@ -298,6 +326,33 @@ export default function AdminPaymentsPage() { return labels[provider] || provider; }; + // Manual gateways need admin verification; automatic ones confirm themselves. + const getProviderKindBadge = (provider: string) => ( + isManualProvider(provider) ? ( + + {locale === 'es' ? 'Manual' : 'Manual'} + + ) : ( + + {locale === 'es' ? 'Automático' : 'Auto'} + + ) + ); + + // Age of a claim/booking, e.g. "3h" / "2d"; used to surface rotting approvals. + const getAgeInfo = (dateStr?: string | null) => { + if (!dateStr) return null; + const ms = Date.now() - parseDate(dateStr).getTime(); + if (ms < 0) return null; + const hours = Math.floor(ms / (60 * 60 * 1000)); + const label = hours < 1 + ? (locale === 'es' ? 'hace <1 h' : '<1h ago') + : hours < 48 + ? (locale === 'es' ? `hace ${hours} h` : `${hours}h ago`) + : (locale === 'es' ? `hace ${Math.floor(hours / 24)} días` : `${Math.floor(hours / 24)}d ago`); + return { hours, label, stale: hours >= 48 }; + }; + // Helper to get booking info for a payment (ticket count and total) const getBookingInfo = (payment: PaymentWithDetails) => { if (!payment.ticket?.bookingId) { @@ -331,6 +386,22 @@ export default function AdminPaymentsPage() { }); })(); + // Manual payments never claimed by the customer — they may have paid and + // forgotten to press "I've paid", so they stay directly approvable here. + // Hidden once the event has ended (same rule as pending approvals above). + const visibleUnclaimedManualPayments = (() => { + const now = new Date(); + return unclaimedManualPayments.filter((payment) => { + const eventId = payment.event?.id; + const fullEvent = eventId ? events.find((e) => e.id === eventId) : undefined; + const endIso = fullEvent?.endDatetime + || fullEvent?.startDatetime + || payment.event?.startDatetime; + if (!endIso) return true; + return parseDate(endIso).getTime() >= now.getTime(); + }); + })(); + // Get booking info for pending approval payments const getPendingBookingInfo = (payment: PaymentWithDetails) => { if (!payment.ticket?.bookingId) { @@ -348,9 +419,15 @@ export default function AdminPaymentsPage() { }; }; - // Calculate totals (sum all individual payment amounts) - const totalPending = payments - .filter(p => p.status === 'pending' || p.status === 'pending_approval') + // Calculate totals (sum all individual payment amounts). + // Claimed ('pending_approval') money is probably already in the account and + // just needs verification; bare 'pending' money may never arrive — keep the + // two apart so the totals don't overstate what's owed. + const totalAwaitingVerification = payments + .filter(p => p.status === 'pending_approval') + .reduce((sum, p) => sum + Number(p.amount), 0); + const totalUnclaimed = payments + .filter(p => p.status === 'pending') .reduce((sum, p) => sum + Number(p.amount), 0); const totalPaid = payments .filter(p => p.status === 'paid') @@ -370,9 +447,6 @@ export default function AdminPaymentsPage() { return count; }; - const pendingBookingsCount = getUniqueBookingsCount( - payments.filter(p => p.status === 'pending' || p.status === 'pending_approval') - ); const paidBookingsCount = getUniqueBookingsCount( payments.filter(p => p.status === 'paid') ); @@ -721,9 +795,12 @@ export default function AdminPaymentsPage() {
-

{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}

-

{formatCurrency(totalPending, 'PYG')}

-

{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}

+

{locale === 'es' ? 'Por Verificar' : 'Awaiting Verification'}

+

{formatCurrency(totalAwaitingVerification, 'PYG')}

+

+ {locale === 'es' ? 'Sin pagar (sin reclamar): ' : 'Unpaid (unclaimed): '} + {formatCurrency(totalUnclaimed, 'PYG')} +

@@ -817,13 +894,18 @@ export default function AdminPaymentsPage() { {getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} + {getProviderKindBadge(payment.provider)} - {payment.userMarkedPaidAt && ( - - - {locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)} - - )} + {payment.userMarkedPaidAt && (() => { + const age = getAgeInfo(payment.userMarkedPaidAt); + return ( + + + {locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)} + {age && ({age.label})} + + ); + })()} {payment.payerName && (

@@ -841,6 +923,55 @@ export default function AdminPaymentsPage() { })} )} + + {/* Manual payments the customer never confirmed — they may have paid + (bank transfer / TPago received) without pressing "I've paid". + Approving one claims a seat, so it goes through the same + over-capacity confirmation as any approval. */} + {visibleUnclaimedManualPayments.length > 0 && ( +

+ + {locale === 'es' + ? `Pagos manuales sin confirmar por el cliente (${visibleUnclaimedManualPayments.length})` + : `Manual payments not yet confirmed by customer (${visibleUnclaimedManualPayments.length})`} + + {locale === 'es' + ? 'Puede que hayan pagado sin presionar "Ya pagué". No reservan lugar hasta ser aprobados.' + : 'They may have paid without pressing "I\'ve paid". These hold no seat until approved.'} + + +
+ {visibleUnclaimedManualPayments.map((payment) => { + const age = getAgeInfo(payment.createdAt); + return ( + +
+
+
+ {getProviderIcon(payment.provider)} +
+
+

+ {payment.ticket?.attendeeFirstName} {payment.ticket?.attendeeLastName} + · {formatCurrency(payment.amount, payment.currency)} +

+

+ {payment.event?.title} + {' · '}{getProviderLabel(payment.provider)} + {age && · {age.label}} +

+
+
+ +
+
+ ); + })} +
+
+ )} )} @@ -1004,6 +1135,7 @@ export default function AdminPaymentsPage() {
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} + {getProviderKindBadge(payment.provider)}
{getStatusBadge(payment.status)} @@ -1063,7 +1195,7 @@ export default function AdminPaymentsPage() {
{formatCurrency(bookingInfo.bookingTotal, payment.currency)} | - {getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} + {getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} {getProviderKindBadge(payment.provider)} {bookingInfo.ticketCount > 1 && ( <>|{bookingInfo.ticketCount} tickets )} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 47737f5..5df222a 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -34,7 +34,12 @@ export async function fetchApi( const errorMessage = typeof errorData.error === 'string' ? errorData.error : (errorData.message || JSON.stringify(errorData) || 'Request failed'); - throw new Error(errorMessage); + const error = new Error(errorMessage); + // Preserve structured error info (e.g. code: 'EVENT_OVER_CAPACITY') so + // callers can react beyond the message text. + (error as any).code = errorData.code; + (error as any).data = errorData; + throw error; } return res.json(); diff --git a/frontend/src/lib/api/payments.ts b/frontend/src/lib/api/payments.ts index b057238..2da9d9d 100644 --- a/frontend/src/lib/api/payments.ts +++ b/frontend/src/lib/api/payments.ts @@ -1,6 +1,14 @@ import { fetchApi } from './client'; import type { Payment, PaymentWithDetails } from './types'; +// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to +// verify the money arrived; automatic ones (lightning) confirm themselves. +export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash']; + +export function isManualProvider(provider: string): boolean { + return MANUAL_PAYMENT_PROVIDERS.includes(provider); +} + export const paymentsApi = { getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => { const query = new URLSearchParams(); @@ -21,10 +29,10 @@ export const paymentsApi = { body: JSON.stringify(data), }), - approve: (id: string, adminNote?: string, sendEmail: boolean = true) => + approve: (id: string, adminNote?: string, sendEmail: boolean = true, allowOverCapacity: boolean = false) => fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, { method: 'POST', - body: JSON.stringify({ adminNote, sendEmail }), + body: JSON.stringify({ adminNote, sendEmail, allowOverCapacity }), }), reject: (id: string, adminNote?: string, sendEmail: boolean = true) => diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 8d5c43a..4d91d77 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -18,8 +18,9 @@ export interface Event { bannerUrl?: string; externalBookingEnabled?: boolean; externalBookingUrl?: string; - bookedCount?: number; - availableSeats?: number; + bookedCount?: number; // paid seats (confirmed + checked_in) + claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats) + availableSeats?: number; // capacity - booked - claimed; the server-authoritative number isFeatured?: boolean; createdAt: string; updatedAt: string; @@ -221,7 +222,10 @@ export interface DashboardData { totalEvents: number; totalTickets: number; confirmedTickets: number; + /** Checkouts opened but never paid nor claimed — informational, holds no seat */ pendingPayments: number; + /** Customer says they paid; needs admin verification — actionable */ + awaitingApprovalPayments: number; totalRevenue: number; newContacts: number; totalSubscribers: number; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index a037978..b91961e 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -205,3 +205,33 @@ export function getTpagoLink( const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig; return config[key] || config.tpagoLink || null; } + +/** + * Spots left for an event, trusting the server's `availableSeats` (which uses + * the same seat-holding formula the booking API enforces: paid + claimed + * seats count, abandoned pending bookings don't). Falls back to deriving it + * from the counts for older API responses. + */ +export function eventSpotsLeft(event: { + capacity: number; + bookedCount?: number; + claimedCount?: number; + availableSeats?: number; +}): number { + if (typeof event.availableSeats === 'number') { + return Math.max(0, event.availableSeats); + } + return Math.max( + 0, + (event.capacity ?? 0) - (event.bookedCount ?? 0) - (event.claimedCount ?? 0) + ); +} + +export function isEventSoldOut(event: { + capacity: number; + bookedCount?: number; + claimedCount?: number; + availableSeats?: number; +}): boolean { + return eventSpotsLeft(event) <= 0; +} -- 2.54.0 From e38d14970d1e1bc06dd47dc924a0764c94e98cdd Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 26 Jul 2026 04:58:52 +0000 Subject: [PATCH 3/4] Harden the Redis layer for production. - Locks no longer fail open: an unreachable backend throws LockUnavailableError and withLock skips the run (or opts into running unlocked, as template seeding does). The lnbits payment poller keeps polling through an outage, made safe by only sending confirmation emails when a row actually transitioned. - Rate limiter INCR+PEXPIRE now runs as one Lua script, self-healing counters stranded without a TTL. - Per-email login lockout moves to a Redis-backed store shared across replicas (in-memory fallback preserved), case-insensitive on email. - Graceful shutdown on SIGTERM/SIGINT: stop periodic jobs, close the server, force-close lingering SSE sockets, close Redis. - Active PING probe backs the health flag; /health reports last ping. SSE payment streams also poll the DB as a pub/sub-gap fallback. - Scale compose gains requirepass, maxmemory 256mb with noeviction, and an authenticated healthcheck; .env.example documents passwords, TLS (rediss://), and DB-index selection. - First tests in the repo: vitest + ioredis-mock covering the lock, rate limiter, and login lockout stores (27 tests). Co-Authored-By: Claude Fable 5 --- backend/.env.example | 14 +- backend/package.json | 5 +- backend/src/index.ts | 52 ++++++- backend/src/lib/backends.ts | 7 +- backend/src/lib/redis.ts | 90 +++++++++-- backend/src/lib/stores/lock.test.ts | 98 ++++++++++++ backend/src/lib/stores/lock.ts | 54 +++++-- backend/src/lib/stores/loginLockout.test.ts | 125 +++++++++++++++ backend/src/lib/stores/loginLockout.ts | 164 ++++++++++++++++++++ backend/src/lib/stores/rateLimiter.test.ts | 84 ++++++++++ backend/src/lib/stores/rateLimiter.ts | 45 ++++-- backend/src/routes/auth.ts | 53 ++----- backend/src/routes/lnbits.ts | 74 +++++++-- backend/tsconfig.json | 2 +- backend/vitest.config.ts | 7 + deploy/docker-compose.scale.yml | 17 +- 16 files changed, 785 insertions(+), 106 deletions(-) create mode 100644 backend/src/lib/stores/lock.test.ts create mode 100644 backend/src/lib/stores/loginLockout.test.ts create mode 100644 backend/src/lib/stores/loginLockout.ts create mode 100644 backend/src/lib/stores/rateLimiter.test.ts create mode 100644 backend/vitest.config.ts diff --git a/backend/.env.example b/backend/.env.example index c1339c5..bed4f2b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,10 +22,18 @@ DATABASE_URL=./data/spanglish.db # Note: running more than one instance requires DB_TYPE=postgres. SQLite is a # single local file and cannot be shared safely across instances. -# Redis connection URL. When set, the cache, rate limiter, pub/sub (real-time -# payment events), distributed locks, and the email hourly cap are shared across -# all instances. When unset, each instance uses in-memory equivalents. +# Redis connection URL. When set, the cache, rate limiter, login lockout, +# pub/sub (real-time payment events), distributed locks, and the email hourly +# cap are shared across all instances. When unset, each instance uses in-memory +# equivalents. +# +# In production always set a password (requirepass on the server) and put it in +# the URL. Use the rediss:// scheme for TLS (handled natively by the client), +# and an optional /N path to select a DB index when sharing a Redis instance +# with other applications. # REDIS_URL=redis://localhost:6379 +# REDIS_URL=redis://:your-redis-password@redis.internal:6379 +# REDIS_URL=rediss://:your-redis-password@redis.example.com:6380/1 # Optional S3-compatible object storage for media uploads (e.g. Garage, MinIO, # AWS S3). When S3_ENDPOINT and S3_BUCKET are set, uploads go to the bucket and diff --git a/backend/package.json b/backend/package.json index a045340..c0c948d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", + "test": "vitest run", "start": "NODE_ENV=production node dist/index.js", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/db/migrate.ts", @@ -42,7 +43,9 @@ "@types/pg": "^8.11.6", "@types/qrcode": "^1.5.6", "drizzle-kit": "^0.22.8", + "ioredis-mock": "^8.13.1", "tsx": "^4.15.7", - "typescript": "^5.5.2" + "typescript": "^5.5.2", + "vitest": "^4.1.10" } } diff --git a/backend/src/index.ts b/backend/src/index.ts index d7773c9..2c8ed7c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,10 +24,11 @@ import legalPagesRoutes from './routes/legal-pages.js'; import legalSettingsRoutes from './routes/legal-settings.js'; import faqRoutes from './routes/faq.js'; import emailService from './lib/email.js'; -import { initEmailQueue } from './lib/emailQueue.js'; -import { startBookingCleanup } from './lib/bookingCleanup.js'; -import { startHoldSweep } from './lib/holdSweep.js'; -import { startEventEndSweep } from './lib/eventEndSweep.js'; +import { initEmailQueue, stopQueue } from './lib/emailQueue.js'; +import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js'; +import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js'; +import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js'; +import { closeRedis } from './lib/redis.js'; import { getLock } from './lib/stores/lock.js'; import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js'; @@ -1943,8 +1944,11 @@ startEventEndSweep(); // Initialize email templates on startup. // Guarded by a distributed lock so that, when running multiple replicas, only // one instance seeds/updates templates per boot instead of all of them racing. +// onUnavailable 'run': at boot the Redis connection may not be ready yet, and +// seeding is upsert-idempotent, so racing replicas are safe — never skipping +// beats never seeding on a first boot during a Redis blip. getLock() - .withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates()) + .withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates(), { onUnavailable: 'run' }) .then((result) => { if (result === null) { console.log('[Email] Template seeding skipped (another instance holds the lock)'); @@ -1961,7 +1965,43 @@ console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`); // Log which backend (memory/redis, local/s3) each subsystem selected. logSelectedBackends(); -serve({ +const server = serve({ fetch: app.fetch, port, }); + +// Graceful shutdown: stop the periodic jobs, stop accepting connections, then +// close Redis and exit. Open SSE payment streams hold sockets forever, so +// server.close() alone never completes — force-close remaining connections +// after a short grace period, with a hard exit as the final backstop. +let shuttingDown = false; +function shutdown(signal: string): void { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[shutdown] ${signal} received, draining...`); + + stopBookingCleanup(); + stopHoldSweep(); + stopEventEndSweep(); + stopQueue(); + + server.close(() => { + console.log('[shutdown] server closed, closing redis'); + closeRedis().finally(() => process.exit(0)); + }); + + const forceClose = setTimeout(() => { + console.warn('[shutdown] force-closing remaining connections (SSE streams)'); + (server as any).closeAllConnections?.(); + }, 5_000); + forceClose.unref(); + + const forceExit = setTimeout(() => { + console.warn('[shutdown] drain timed out, forcing exit'); + process.exit(1); + }, 10_000); + forceExit.unref(); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/backend/src/lib/backends.ts b/backend/src/lib/backends.ts index 63196a9..c2eec12 100644 --- a/backend/src/lib/backends.ts +++ b/backend/src/lib/backends.ts @@ -1,11 +1,12 @@ // Reports which backend each scalable subsystem is using, for the health // endpoint and startup logging. -import { isRedisEnabled, isRedisHealthy } from './redis.js'; +import { isRedisEnabled, isRedisHealthy, getRedisHealthDetail } from './redis.js'; import { getRateLimiter } from './stores/rateLimiter.js'; import { getPubSub } from './stores/pubsub.js'; import { getCache } from './stores/cache.js'; import { getLock } from './stores/lock.js'; +import { getLoginLockout } from './stores/loginLockout.js'; import { getStorage } from './storage.js'; export function describeBackends() { @@ -14,12 +15,13 @@ export function describeBackends() { rateLimiter: getRateLimiter().backend, pubsub: getPubSub().backend, lock: getLock().backend, + loginLockout: getLoginLockout().backend, storage: getStorage().backend, }; } export function describeRedis() { - return { enabled: isRedisEnabled(), healthy: isRedisHealthy() }; + return { enabled: isRedisEnabled(), healthy: isRedisHealthy(), ...getRedisHealthDetail() }; } /** Log one line per subsystem at startup so the active backend is obvious. */ @@ -32,5 +34,6 @@ export function logSelectedBackends(): void { console.log(` rate limiter: ${b.rateLimiter}`); console.log(` pub/sub: ${b.pubsub}`); console.log(` lock: ${b.lock}`); + console.log(` login lockout:${b.loginLockout}`); console.log(` storage: ${b.storage}`); } diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts index 4c53bca..ccdca88 100644 --- a/backend/src/lib/redis.ts +++ b/backend/src/lib/redis.ts @@ -4,7 +4,15 @@ // before with in-memory backends. When set, this module owns a single shared // command connection plus a dedicated subscriber connection (a connection in // subscribe mode cannot run normal commands), with auto-reconnect, capped -// backoff, and a health flag that callers and the health endpoint can read. +// backoff, an active PING probe, and a health flag that callers and the health +// endpoint can read. +// +// TLS: use a rediss:// URL — ioredis enables TLS from the scheme. A /N path +// selects a DB index (e.g. redis://host:6379/1) when sharing an instance. +// We deliberately do not use ioredis's keyPrefix option: all keys are already +// namespaced per subsystem (cache:, rl:, lock:, lockout:), and keyPrefix has a +// pub/sub asymmetry (SUBSCRIBE channels get prefixed, PUBLISH channels do not) +// that would silently break the payment SSE channel. import Redis from 'ioredis'; @@ -12,6 +20,14 @@ let client: Redis | null = null; let subscriber: Redis | null = null; let healthy = false; let initialized = false; +let pingTimer: ReturnType | null = null; +let lastPingOkAt: string | null = null; +let lastPingMs: number | null = null; + +// Debounce repeated error logs during a sustained outage: transitions are +// always logged, repeated per-retry errors at most once per LOG_EVERY_MS. +const ERROR_LOG_EVERY_MS = 30_000; +let lastErrorLogAt = 0; /** Whether Redis is configured via REDIS_URL. */ export function isRedisEnabled(): boolean { @@ -23,14 +39,41 @@ export function isRedisHealthy(): boolean { return isRedisEnabled() && healthy; } +/** Detail for the health endpoint: when the last successful PING happened. */ +export function getRedisHealthDetail(): { lastPingOkAt: string | null; lastPingMs: number | null } { + return { lastPingOkAt, lastPingMs }; +} + +function setHealthy(next: boolean, context: string): void { + if (next !== healthy) { + if (next) { + console.log(`[redis] healthy (${context})`); + } else { + console.warn(`[redis] unhealthy (${context})`); + } + } + healthy = next; +} + +function logErrorDebounced(label: string, err: unknown): void { + const now = Date.now(); + if (now - lastErrorLogAt >= ERROR_LOG_EVERY_MS) { + lastErrorLogAt = now; + console.error(`[redis] (${label}) error:`, (err as any)?.message || err); + } +} + function buildClient(label: string): Redis { const url = process.env.REDIS_URL as string; const instance = new Redis(url, { // Keep the process responsive: fail fast on a per-command basis and let the - // callers degrade to their in-memory fallback rather than hanging. + // callers degrade to their in-memory fallback rather than hanging. Do not + // queue commands while disconnected — with fail-open callers everywhere a + // growing offline queue would only add latency and memory pressure. maxRetriesPerRequest: 1, enableOfflineQueue: false, lazyConnect: false, + connectTimeout: 5000, retryStrategy(times) { // Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s. const delay = Math.min(times * 200, 5000); @@ -42,30 +85,53 @@ function buildClient(label: string): Redis { console.log(`[redis] (${label}) connecting`); }); instance.on('ready', () => { - healthy = true; - console.log(`[redis] (${label}) ready`); + setHealthy(true, `${label} ready`); }); instance.on('error', (err) => { - healthy = false; - console.error(`[redis] (${label}) error:`, err?.message || err); + setHealthy(false, `${label} error`); + logErrorDebounced(label, err); }); instance.on('reconnecting', () => { - healthy = false; - console.warn(`[redis] (${label}) reconnecting`); + setHealthy(false, `${label} reconnecting`); }); instance.on('end', () => { - healthy = false; - console.warn(`[redis] (${label}) connection closed`); + setHealthy(false, `${label} connection closed`); }); return instance; } +// Actively probe the command connection. The event-driven flag alone misses a +// silently hung connection; a periodic PING with a hard timeout catches it. +async function pingOnce(): Promise { + if (!client) return; + const started = Date.now(); + try { + await Promise.race([ + client.ping(), + new Promise((_, reject) => { + const t = setTimeout(() => reject(new Error('ping timeout')), 2000); + (t as any).unref?.(); + }), + ]); + lastPingMs = Date.now() - started; + lastPingOkAt = new Date().toISOString(); + setHealthy(true, 'ping ok'); + } catch (err) { + setHealthy(false, 'ping failed'); + logErrorDebounced('ping', err); + } +} + function ensureInit(): void { if (initialized || !isRedisEnabled()) return; initialized = true; client = buildClient('commands'); subscriber = buildClient('subscriber'); + pingTimer = setInterval(() => { + void pingOnce(); + }, 10_000); + (pingTimer as any).unref?.(); } /** Shared command connection, or null when Redis is not configured. */ @@ -82,6 +148,10 @@ export function getSubscriber(): Redis | null { /** Close connections (used for graceful shutdown). */ export async function closeRedis(): Promise { + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } const tasks: Promise[] = []; if (client) tasks.push(client.quit().catch(() => undefined)); if (subscriber) tasks.push(subscriber.quit().catch(() => undefined)); diff --git a/backend/src/lib/stores/lock.test.ts b/backend/src/lib/stores/lock.test.ts new file mode 100644 index 0000000..1c5b64e --- /dev/null +++ b/backend/src/lib/stores/lock.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import RedisMock from 'ioredis-mock'; + +const mocks = vi.hoisted(() => ({ redis: null as any })); + +vi.mock('../redis.js', () => ({ + isRedisEnabled: () => true, + getRedis: () => mocks.redis, +})); + +import { MemoryLock, RedisLock, LockUnavailableError } from './lock.js'; + +describe('MemoryLock', () => { + it('grants the lock, blocks contenders, and frees on release', async () => { + const lock = new MemoryLock(); + const token = await lock.acquire('a', 60_000); + expect(token).toBeTruthy(); + expect(await lock.acquire('a', 60_000)).toBeNull(); + await lock.release('a', token!); + expect(await lock.acquire('a', 60_000)).toBeTruthy(); + }); + + it('ignores release with the wrong token', async () => { + const lock = new MemoryLock(); + const token = await lock.acquire('a', 60_000); + await lock.release('a', 'not-the-token'); + expect(await lock.acquire('a', 60_000)).toBeNull(); + await lock.release('a', token!); + }); + + it('withLock runs fn while held and returns null under contention', async () => { + const lock = new MemoryLock(); + const held = await lock.acquire('a', 60_000); + expect(await lock.withLock('a', 60_000, async () => 'ran')).toBeNull(); + await lock.release('a', held!); + expect(await lock.withLock('a', 60_000, async () => 'ran')).toBe('ran'); + // Released after withLock completes. + expect(await lock.acquire('a', 60_000)).toBeTruthy(); + }); +}); + +describe('RedisLock', () => { + beforeEach(async () => { + mocks.redis = new RedisMock(); + // ioredis-mock shares data between instances by connection string. + await mocks.redis.flushall(); + }); + + it('grants the lock and blocks contenders', async () => { + const lock = new RedisLock(); + const token = await lock.acquire('a', 60_000); + expect(token).toBeTruthy(); + expect(await lock.acquire('a', 60_000)).toBeNull(); + }); + + it('release is compare-and-delete: wrong token does not free the lock', async () => { + const lock = new RedisLock(); + const token = await lock.acquire('a', 60_000); + await lock.release('a', 'not-the-token'); + expect(await lock.acquire('a', 60_000)).toBeNull(); + await lock.release('a', token!); + expect(await lock.acquire('a', 60_000)).toBeTruthy(); + }); + + it('throws LockUnavailableError when the client is not initialized', async () => { + mocks.redis = null; + const lock = new RedisLock(); + await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError); + }); + + it('throws LockUnavailableError when the backend errors', async () => { + mocks.redis = { set: () => Promise.reject(new Error('connection refused')) }; + const lock = new RedisLock(); + await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError); + }); + + it('withLock skips (returns null, fn not called) when unavailable by default', async () => { + mocks.redis = null; + const lock = new RedisLock(); + const fn = vi.fn(async () => 'ran'); + expect(await lock.withLock('a', 60_000, fn)).toBeNull(); + expect(fn).not.toHaveBeenCalled(); + }); + + it('withLock runs fn without the lock when onUnavailable is "run"', async () => { + mocks.redis = null; + const lock = new RedisLock(); + const fn = vi.fn(async () => 'ran'); + expect(await lock.withLock('a', 60_000, fn, { onUnavailable: 'run' })).toBe('ran'); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('withLock returns fn result and releases the lock afterwards', async () => { + const lock = new RedisLock(); + expect(await lock.withLock('a', 60_000, async () => 42)).toBe(42); + expect(await lock.acquire('a', 60_000)).toBeTruthy(); + }); +}); diff --git a/backend/src/lib/stores/lock.ts b/backend/src/lib/stores/lock.ts index 10950ae..e673031 100644 --- a/backend/src/lib/stores/lock.ts +++ b/backend/src/lib/stores/lock.ts @@ -5,22 +5,45 @@ // // Use acquire/release for long-lived ownership (e.g. a background poller) and // withLock for a one-shot critical section. Selection is based on REDIS_URL. +// +// There is deliberately no auto-renewal/watchdog: every guarded section (sweep +// jobs, template seeding) is a handful of status-conditional bulk UPDATEs that +// finish far below the lock TTL, and a rare TTL overrun only risks one +// idempotent overlapping run. Keep TTLs generous instead of adding renewal. import { randomUUID } from 'crypto'; import { getRedis, isRedisEnabled } from '../redis.js'; +// Thrown when Redis is configured but the lock backend cannot be reached. +// This is distinct from contention (acquire resolves null): the caller must +// decide whether its critical section is safe to run without mutual exclusion. +export class LockUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'LockUnavailableError'; + } +} + +export interface WithLockOptions { + // What withLock should do when the lock backend is unavailable (not mere + // contention): 'skip' (default) returns null as if the lock were held + // elsewhere; 'run' executes fn without mutual exclusion. + onUnavailable?: 'skip' | 'run'; +} + export interface Lock { readonly backend: 'memory' | 'redis'; // Returns a token when the lock was acquired, or null when already held. + // Throws LockUnavailableError when the backend is configured but erroring. acquire(key: string, ttlMs: number): Promise; release(key: string, token: string): Promise; // Runs fn while holding the lock; returns fn's result, or null if not acquired. - withLock(key: string, ttlMs: number, fn: () => Promise): Promise; + withLock(key: string, ttlMs: number, fn: () => Promise, opts?: WithLockOptions): Promise; } // ==================== Memory implementation ==================== -class MemoryLock implements Lock { +export class MemoryLock implements Lock { readonly backend = 'memory' as const; private held = new Map(); @@ -58,23 +81,23 @@ class MemoryLock implements Lock { const RELEASE_SCRIPT = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end'; -class RedisLock implements Lock { +export class RedisLock implements Lock { readonly backend = 'redis' as const; async acquire(key: string, ttlMs: number): Promise { const redis = getRedis(); if (!redis) { - // Redis configured but unavailable: do not block critical sections. - return randomUUID(); + throw new LockUnavailableError('redis client not initialized'); } const token = randomUUID(); try { const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX'); return result === 'OK' ? token : null; } catch (err: any) { - console.error('[lock] redis acquire error, proceeding without lock:', err?.message || err); - // Fail open so a Redis outage does not deadlock startup or jobs. - return randomUUID(); + // Do NOT fabricate a token here: during an outage every replica would + // "acquire" every lock and run the guarded sections concurrently. Let the + // caller choose between skipping the run and running unlocked. + throw new LockUnavailableError(err?.message || String(err)); } } @@ -88,8 +111,19 @@ class RedisLock implements Lock { } } - async withLock(key: string, ttlMs: number, fn: () => Promise): Promise { - const token = await this.acquire(key, ttlMs); + async withLock(key: string, ttlMs: number, fn: () => Promise, opts?: WithLockOptions): Promise { + let token: string | null; + try { + token = await this.acquire(key, ttlMs); + } catch (err) { + if (!(err instanceof LockUnavailableError)) throw err; + if (opts?.onUnavailable === 'run') { + console.warn(`[lock] backend unavailable, running "${key}" WITHOUT mutual exclusion:`, err.message); + return fn(); + } + console.warn(`[lock] backend unavailable, skipping "${key}" this run:`, err.message); + return null; + } if (!token) return null; try { return await fn(); diff --git a/backend/src/lib/stores/loginLockout.test.ts b/backend/src/lib/stores/loginLockout.test.ts new file mode 100644 index 0000000..c3a02ef --- /dev/null +++ b/backend/src/lib/stores/loginLockout.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import RedisMock from 'ioredis-mock'; + +const mocks = vi.hoisted(() => ({ redis: null as any })); + +vi.mock('../redis.js', () => ({ + isRedisEnabled: () => true, + getRedis: () => mocks.redis, +})); + +import { + MemoryLoginLockout, + RedisLoginLockout, + MAX_LOGIN_ATTEMPTS, +} from './loginLockout.js'; + +describe('MemoryLoginLockout', () => { + it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => { + const lockout = new MemoryLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) { + await lockout.recordFailure('user@example.com'); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + } + await lockout.recordFailure('user@example.com'); + const status = await lockout.isLocked('user@example.com'); + expect(status.locked).toBe(true); + expect(status.retryAfter).toBeGreaterThan(0); + }); + + it('clear removes the lockout', async () => { + const lockout = new MemoryLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { + await lockout.recordFailure('user@example.com'); + } + await lockout.clear('user@example.com'); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + }); + + it('treats emails case-insensitively', async () => { + const lockout = new MemoryLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { + await lockout.recordFailure('User@Example.com'); + } + expect((await lockout.isLocked('user@example.com')).locked).toBe(true); + }); + + it('expires after the lockout window', async () => { + vi.useFakeTimers(); + try { + const lockout = new MemoryLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { + await lockout.recordFailure('user@example.com'); + } + expect((await lockout.isLocked('user@example.com')).locked).toBe(true); + vi.advanceTimersByTime(16 * 60 * 1000); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('RedisLoginLockout', () => { + beforeEach(async () => { + mocks.redis = new RedisMock(); + // ioredis-mock shares data between instances by connection string. + await mocks.redis.flushall(); + }); + + it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => { + const lockout = new RedisLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) { + await lockout.recordFailure('user@example.com'); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + } + await lockout.recordFailure('user@example.com'); + const status = await lockout.isLocked('user@example.com'); + expect(status.locked).toBe(true); + expect(status.retryAfter).toBeGreaterThan(0); + }); + + it('sets the lockout window TTL on the first failure', async () => { + const lockout = new RedisLoginLockout(); + await lockout.recordFailure('user@example.com'); + const ttl = await mocks.redis.pttl('lockout:user@example.com'); + expect(ttl).toBeGreaterThan(0); + }); + + it('clear removes the lockout', async () => { + const lockout = new RedisLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { + await lockout.recordFailure('user@example.com'); + } + await lockout.clear('user@example.com'); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + }); + + it('treats emails case-insensitively', async () => { + const lockout = new RedisLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { + await lockout.recordFailure('User@Example.com'); + } + expect((await lockout.isLocked('user@example.com')).locked).toBe(true); + }); + + it('fails open when the client is not initialized', async () => { + mocks.redis = null; + const lockout = new RedisLoginLockout(); + await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined(); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + }); + + it('fails open when the backend errors', async () => { + mocks.redis = { + get: () => Promise.reject(new Error('connection refused')), + pttl: () => Promise.reject(new Error('connection refused')), + del: () => Promise.reject(new Error('connection refused')), + lockoutRecordFailure: () => Promise.reject(new Error('connection refused')), + }; + const lockout = new RedisLoginLockout(); + await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined(); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + await expect(lockout.clear('user@example.com')).resolves.toBeUndefined(); + }); +}); diff --git a/backend/src/lib/stores/loginLockout.ts b/backend/src/lib/stores/loginLockout.ts new file mode 100644 index 0000000..c821078 --- /dev/null +++ b/backend/src/lib/stores/loginLockout.ts @@ -0,0 +1,164 @@ +// Per-email login lockout abstraction with two implementations: +// - memory: per-process Map (the original routes/auth.ts behavior) +// - redis: shared counter across all instances so the lockout cannot be +// bypassed by round-robining replicas +// +// Semantics: recordFailure starts a window on the first failure; once the +// failure count reaches the max the email is locked until the window expires; +// clear removes the counter on successful login. Selection is based on +// REDIS_URL. The redis implementation fails open (never locked, failures not +// recorded) — the per-IP auth rate limit remains as a backstop during a +// Redis outage. + +import type Redis from 'ioredis'; +import { getRedis, isRedisEnabled } from '../redis.js'; + +export const MAX_LOGIN_ATTEMPTS = 5; +export const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes + +export interface LockoutStatus { + locked: boolean; + // Seconds until the lockout lifts; only set when locked. + retryAfter?: number; +} + +export interface LoginLockout { + readonly backend: 'memory' | 'redis'; + isLocked(email: string): Promise; + recordFailure(email: string): Promise; + clear(email: string): Promise; +} + +// Emails are compared case-insensitively so "User@x.com" and "user@x.com" +// share one counter. +function normalize(email: string): string { + return email.trim().toLowerCase(); +} + +// ==================== Memory implementation ==================== + +export class MemoryLoginLockout implements LoginLockout { + readonly backend = 'memory' as const; + private attempts = new Map(); + + constructor() { + // Periodically drop expired entries so the Map does not grow unbounded. + const cleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of this.attempts) { + if (now > entry.resetAt) this.attempts.delete(key); + } + }, 60_000); + (cleanup as any).unref?.(); + } + + async isLocked(email: string): Promise { + const entry = this.attempts.get(normalize(email)); + const now = Date.now(); + if (!entry || now > entry.resetAt) return { locked: false }; + if (entry.count >= MAX_LOGIN_ATTEMPTS) { + return { locked: true, retryAfter: Math.ceil((entry.resetAt - now) / 1000) }; + } + return { locked: false }; + } + + async recordFailure(email: string): Promise { + const key = normalize(email); + const now = Date.now(); + const entry = this.attempts.get(key); + if (!entry || now > entry.resetAt) { + this.attempts.set(key, { count: 1, resetAt: now + LOCKOUT_DURATION_MS }); + return; + } + entry.count++; + } + + async clear(email: string): Promise { + this.attempts.delete(normalize(email)); + } +} + +// ==================== Redis implementation ==================== + +// Same atomic INCR+PEXPIRE shape as the rate limiter's consume script: the +// window starts at the first failure and any TTL-less counter is repaired. +const RECORD_FAILURE_SCRIPT = ` +local count = redis.call('INCR', KEYS[1]) +if count == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end +if redis.call('PTTL', KEYS[1]) < 0 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end +return count +`; + +type RedisWithLockout = Redis & { + lockoutRecordFailure(key: string, windowMs: number): Promise; +}; + +function withLockoutCommand(redis: Redis): RedisWithLockout { + if (typeof (redis as any).lockoutRecordFailure !== 'function') { + redis.defineCommand('lockoutRecordFailure', { numberOfKeys: 1, lua: RECORD_FAILURE_SCRIPT }); + } + return redis as RedisWithLockout; +} + +export class RedisLoginLockout implements LoginLockout { + readonly backend = 'redis' as const; + + private key(email: string): string { + return `lockout:${normalize(email)}`; + } + + async isLocked(email: string): Promise { + const redis = getRedis(); + if (!redis) return { locked: false }; + try { + const [count, ttl] = await Promise.all([ + redis.get(this.key(email)), + redis.pttl(this.key(email)), + ]); + if (count !== null && parseInt(count, 10) >= MAX_LOGIN_ATTEMPTS) { + const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(LOCKOUT_DURATION_MS / 1000); + return { locked: true, retryAfter }; + } + return { locked: false }; + } catch (err: any) { + // Fail open: the per-IP auth rate limit still applies. + console.error('[loginLockout] redis error, treating as unlocked:', err?.message || err); + return { locked: false }; + } + } + + async recordFailure(email: string): Promise { + const redis = getRedis(); + if (!redis) return; + try { + await withLockoutCommand(redis).lockoutRecordFailure(this.key(email), LOCKOUT_DURATION_MS); + } catch (err: any) { + console.error('[loginLockout] redis error recording failure:', err?.message || err); + } + } + + async clear(email: string): Promise { + const redis = getRedis(); + if (!redis) return; + try { + await redis.del(this.key(email)); + } catch (err: any) { + console.error('[loginLockout] redis error clearing failures:', err?.message || err); + } + } +} + +// ==================== Selection ==================== + +let instance: LoginLockout | null = null; + +export function getLoginLockout(): LoginLockout { + if (!instance) { + instance = isRedisEnabled() ? new RedisLoginLockout() : new MemoryLoginLockout(); + } + return instance; +} diff --git a/backend/src/lib/stores/rateLimiter.test.ts b/backend/src/lib/stores/rateLimiter.test.ts new file mode 100644 index 0000000..c53b612 --- /dev/null +++ b/backend/src/lib/stores/rateLimiter.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import RedisMock from 'ioredis-mock'; + +const mocks = vi.hoisted(() => ({ redis: null as any })); + +vi.mock('../redis.js', () => ({ + isRedisEnabled: () => true, + getRedis: () => mocks.redis, +})); + +import { MemoryRateLimiter, RedisRateLimiter } from './rateLimiter.js'; + +describe('MemoryRateLimiter', () => { + it('allows up to max within the window, then blocks with retryAfter', async () => { + const limiter = new MemoryRateLimiter(); + for (let i = 0; i < 3; i++) { + expect((await limiter.consume('k', 3, 60_000)).allowed).toBe(true); + } + const blocked = await limiter.consume('k', 3, 60_000); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfter).toBeGreaterThan(0); + expect(blocked.retryAfter).toBeLessThanOrEqual(60); + }); + + it('resets after the window elapses', async () => { + vi.useFakeTimers(); + try { + const limiter = new MemoryRateLimiter(); + expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true); + expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(false); + vi.advanceTimersByTime(1_500); + expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('RedisRateLimiter', () => { + beforeEach(async () => { + mocks.redis = new RedisMock(); + // ioredis-mock shares data between instances by connection string. + await mocks.redis.flushall(); + }); + + it('sets the window TTL atomically on the first hit', async () => { + const limiter = new RedisRateLimiter(); + expect((await limiter.consume('k', 5, 60_000)).allowed).toBe(true); + const ttl = await mocks.redis.pttl('rl:k'); + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(60_000); + }); + + it('blocks over the limit with a sane retryAfter', async () => { + const limiter = new RedisRateLimiter(); + for (let i = 0; i < 2; i++) { + expect((await limiter.consume('k', 2, 60_000)).allowed).toBe(true); + } + const blocked = await limiter.consume('k', 2, 60_000); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfter).toBeGreaterThan(0); + expect(blocked.retryAfter).toBeLessThanOrEqual(60); + }); + + it('self-heals a counter stranded without a TTL', async () => { + await mocks.redis.set('rl:k', '3'); + expect(await mocks.redis.pttl('rl:k')).toBeLessThan(0); + const limiter = new RedisRateLimiter(); + await limiter.consume('k', 10, 60_000); + expect(await mocks.redis.pttl('rl:k')).toBeGreaterThan(0); + }); + + it('fails open when the backend errors', async () => { + mocks.redis = { rlConsume: () => Promise.reject(new Error('connection refused')) }; + const limiter = new RedisRateLimiter(); + expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true); + }); + + it('fails open when the client is not initialized', async () => { + mocks.redis = null; + const limiter = new RedisRateLimiter(); + expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true); + }); +}); diff --git a/backend/src/lib/stores/rateLimiter.ts b/backend/src/lib/stores/rateLimiter.ts index 6cf9f34..8e9ef21 100644 --- a/backend/src/lib/stores/rateLimiter.ts +++ b/backend/src/lib/stores/rateLimiter.ts @@ -1,10 +1,13 @@ // Rate limiter abstraction with two implementations: // - memory: per-process fixed window (the original behavior) -// - redis: shared fixed window across all instances (INCR + PEXPIRE) +// - redis: shared fixed window across all instances, implemented as a single +// Lua script so INCR and PEXPIRE are atomic (a crash between separate calls +// would otherwise strand a counter with no expiry) // // Selection happens once based on REDIS_URL. On any Redis error the limiter // fails open (allows the request) so a Redis blip never takes the API down. +import type Redis from 'ioredis'; import { getRedis, isRedisEnabled } from '../redis.js'; export interface RateLimitResult { @@ -24,7 +27,7 @@ interface Bucket { resetAt: number; } -class MemoryRateLimiter implements RateLimiter { +export class MemoryRateLimiter implements RateLimiter { readonly backend = 'memory' as const; private buckets = new Map(); @@ -58,22 +61,44 @@ class MemoryRateLimiter implements RateLimiter { // ==================== Redis implementation ==================== -class RedisRateLimiter implements RateLimiter { +// Atomically increments the window counter, sets the expiry on the first hit, +// and repairs any counter left without a TTL (self-heals keys stranded by the +// pre-Lua implementation or a lost PEXPIRE). Returns {count, ttlMs}. +export const CONSUME_SCRIPT = ` +local count = redis.call('INCR', KEYS[1]) +if count == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end +local ttl = redis.call('PTTL', KEYS[1]) +if ttl < 0 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + ttl = tonumber(ARGV[1]) +end +return {count, ttl} +`; + +type RedisWithConsume = Redis & { + rlConsume(key: string, windowMs: number): Promise<[number, number]>; +}; + +function withConsumeCommand(redis: Redis): RedisWithConsume { + if (typeof (redis as any).rlConsume !== 'function') { + // ioredis caches the script SHA and transparently handles NOSCRIPT. + redis.defineCommand('rlConsume', { numberOfKeys: 1, lua: CONSUME_SCRIPT }); + } + return redis as RedisWithConsume; +} + +export class RedisRateLimiter implements RateLimiter { readonly backend = 'redis' as const; async consume(key: string, max: number, windowMs: number): Promise { const redis = getRedis(); if (!redis) return { allowed: true }; - const redisKey = `rl:${key}`; try { - const count = await redis.incr(redisKey); - if (count === 1) { - // First hit in this window: set the expiry that defines the window. - await redis.pexpire(redisKey, windowMs); - } + const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs); if (count > max) { - const ttl = await redis.pttl(redisKey); const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000); return { allowed: false, retryAfter }; } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 8e65ee0..2528602 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -21,6 +21,7 @@ import { import { generateId, getNow, toDbBool } from '../lib/utils.js'; import { sendEmail } from '../lib/email.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; +import { getLoginLockout } from '../lib/stores/loginLockout.js'; // Per-IP rate limit for sensitive auth endpoints (registration, login, and all // email-dispatching flows) to curb credential stuffing and email flooding. @@ -36,42 +37,6 @@ type AuthUser = User & { const auth = new Hono(); -// Rate limiting store (in production, use Redis) -const loginAttempts = new Map(); -const MAX_LOGIN_ATTEMPTS = 5; -const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes - -function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } { - const now = Date.now(); - const attempts = loginAttempts.get(email); - - if (!attempts) { - return { allowed: true }; - } - - if (now > attempts.resetAt) { - loginAttempts.delete(email); - return { allowed: true }; - } - - if (attempts.count >= MAX_LOGIN_ATTEMPTS) { - return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) }; - } - - return { allowed: true }; -} - -function recordFailedAttempt(email: string): void { - const now = Date.now(); - const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION }; - attempts.count++; - loginAttempts.set(email, attempts); -} - -function clearFailedAttempts(email: string): void { - loginAttempts.delete(email); -} - const registerSchema = z.object({ email: z.string().email(), password: z.string().min(10, 'Password must be at least 10 characters'), @@ -188,12 +153,12 @@ auth.post('/register', authRateLimit, zValidator('json', registerSchema), async auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => { const data = c.req.valid('json'); - // Check rate limit - const rateLimit = checkRateLimit(data.email); - if (!rateLimit.allowed) { - return c.json({ + // Per-email lockout (shared across instances when Redis is configured). + const lockout = await getLoginLockout().isLocked(data.email); + if (lockout.locked) { + return c.json({ error: 'Too many login attempts. Please try again later.', - retryAfter: rateLimit.retryAfter + retryAfter: lockout.retryAfter }, 429); } @@ -201,7 +166,7 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => (db as any).select().from(users).where(eq((users as any).email, data.email)) ); if (!user) { - recordFailedAttempt(data.email); + await getLoginLockout().recordFailure(data.email); return c.json({ error: 'Invalid credentials' }, 401); } @@ -223,12 +188,12 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => const validPassword = await verifyPassword(data.password, user.password); if (!validPassword) { - recordFailedAttempt(data.email); + await getLoginLockout().recordFailure(data.email); return c.json({ error: 'Invalid credentials' }, 401); } // Clear failed attempts on successful login - clearFailedAttempts(data.email); + await getLoginLockout().clear(data.email); // Transparently upgrade legacy bcrypt hashes to argon2 now that we have the // plaintext and have verified it. Best-effort: a failure here must not block diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index af34907..46efe29 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -12,7 +12,7 @@ import { } from '../lib/lnbits.js'; import emailService from '../lib/email.js'; import { getPubSub } from '../lib/stores/pubsub.js'; -import { getLock } from '../lib/stores/lock.js'; +import { getLock, LockUnavailableError } from '../lib/stores/lock.js'; const lnbitsRouter = new Hono(); @@ -106,12 +106,26 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp const expiryMs = expirySeconds * 1000; - const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs); - if (!lockToken) { + let lockToken: string | null = null; + let lockUnavailable = false; + try { + lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs); + } catch (err) { + if (!(err instanceof LockUnavailableError)) throw err; + // Fail open: in webhook-less deployments this poller is the only way a + // Lightning payment gets confirmed, so a Redis outage must not stop it. + // Duplicate pollers across replicas are harmless because + // handlePaymentComplete only acts on rows it actually transitions. + console.warn(`[lnbits] lock backend unavailable, polling ticket ${ticketId} without lock:`, err.message); + lockUnavailable = true; + } + if (!lockToken && !lockUnavailable) { // Another instance is already polling this ticket. return; } - checkerLockTokens.set(ticketId, lockToken); + if (lockToken) { + checkerLockTokens.set(ticketId, lockToken); + } const startTime = Date.now(); let checkCount = 0; @@ -270,25 +284,35 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) { } // Confirm all tickets in the booking (idempotent: only flip pending -> confirmed) + let transitioned = 0; for (const ticket of ticketsToConfirm) { - await (db as any) + const result: any = await (db as any) .update(tickets) .set({ status: 'confirmed' }) .where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending'))); - + transitioned += result?.changes ?? result?.rowCount ?? 0; + await (db as any) .update(payments) - .set({ + .set({ status: 'paid', reference: paymentHash, paidAt: now, updatedAt: now, }) .where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending'))); - + console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`); } - + + // Only the caller that actually flipped rows sends the emails. The webhook + // and the background poller (and pollers on multiple replicas during a Redis + // outage) can all land here; without this guard they would each email. + if (transitioned === 0) { + console.log(`Ticket ${ticketId} was already confirmed by a concurrent caller, skipping emails`); + return; + } + // Get primary payment for sending receipt const payment = await dbGet( (db as any) @@ -296,7 +320,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) { .from(payments) .where(eq((payments as any).ticketId, ticketId)) ); - + // Send confirmation emails asynchronously // For multi-ticket bookings, send email with all ticket info Promise.all([ @@ -372,8 +396,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { } }, 15000); - // Clean up on disconnect - stream.onAbort(() => { + const cleanup = () => { clearInterval(heartbeat); const connections = activeConnections.get(ticketId); if (connections) { @@ -388,11 +411,28 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { } } } - }); - - // Keep the stream open - while (true) { - await stream.sleep(30000); + }; + + // Clean up on disconnect + stream.onAbort(cleanup); + + // Keep the stream open, and every 15s fall back to reading the ticket + // status from the DB. Pub/sub is best-effort: a message published while the + // subscriber connection was reconnecting is lost, and without this check + // the client would wait forever on a payment that already confirmed. + try { + while (true) { + await stream.sleep(15000); + const current = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) + ); + if (current?.status === 'confirmed') { + await sendEvent({ type: 'paid', ticketId }); + return; + } + } + } finally { + cleanup(); } }); }); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 83e9234..d483629 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -12,5 +12,5 @@ "resolveJsonModule": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..6ec74ee --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/deploy/docker-compose.scale.yml b/deploy/docker-compose.scale.yml index ed19405..1d89db8 100644 --- a/deploy/docker-compose.scale.yml +++ b/deploy/docker-compose.scale.yml @@ -28,7 +28,20 @@ services: redis: image: redis:7-alpine - command: ["redis-server", "--appendonly", "yes"] + # noeviction is deliberate: rate-limit, lock, and lockout keys must never + # be evicted for correctness. The cache workload is a single short-TTL key, + # so memory pressure is negligible; if caching ever grows, revisit this or + # move the cache to its own DB index. + command: + [ + "redis-server", + "--appendonly", "yes", + "--requirepass", "${REDIS_PASSWORD:-change-me-redis-password}", + "--maxmemory", "256mb", + "--maxmemory-policy", "noeviction", + ] + environment: + REDISCLI_AUTH: "${REDIS_PASSWORD:-change-me-redis-password}" volumes: - redisdata:/data healthcheck: @@ -46,7 +59,7 @@ services: DB_TYPE: postgres DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish DB_POOL_MAX: "15" - REDIS_URL: redis://redis:6379 + REDIS_URL: "redis://:${REDIS_PASSWORD:-change-me-redis-password}@redis:6379" JWT_SECRET: change-me-to-a-strong-secret FRONTEND_URL: http://localhost:8080 # Optional S3-compatible storage so uploads are shared across replicas. -- 2.54.0 From 9b2668f498f57327a9cb4f9f5879cfeef6340949 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 26 Jul 2026 05:25:00 +0000 Subject: [PATCH 4/4] Unify admin ticket creation into one modal with first-class payment status. - Replace the three Attendees-tab modals (Manual Ticket / Add at Door / Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented control, shared fields, "Check in now" for all types, and a live "what happens" preview, backed by one POST /api/tickets/admin/add. - Add tickets.payment_status (paid | unpaid | comp) with a backfill migration; keep it in sync on every payment-settlement path (mark-paid, admin approval, Lightning, free bookings, hold recovery). - Show Paid/Unpaid/Comp badges in the attendee list, count only paid tickets toward revenue, let unpaid tickets be resolved via Mark Paid, and flag unpaid tickets with their balance due in the door scanner. - Replace the per-page useStatsPrivacy hook with an admin-wide PrivacyContext + SensitiveValue mask, toggled from the admin layout. - Add server-side pagination with page-size options to the users page. Co-Authored-By: Claude Fable 5 --- backend/src/db/migrate.ts | 26 +- backend/src/db/schema.ts | 4 + backend/src/lib/holdRecovery.ts | 10 +- backend/src/routes/lnbits.ts | 2 +- backend/src/routes/payments.ts | 2 +- backend/src/routes/tickets.ts | 268 ++++++---------- .../events/[id]/_components/StatusBadge.tsx | 20 ++ .../events/[id]/_modals/AddTicketModal.tsx | 209 +++++++++++++ .../admin/events/[id]/_modals/EventModals.tsx | 293 +----------------- .../admin/events/[id]/_tabs/AttendeesTab.tsx | 56 ++-- .../admin/events/[id]/_tabs/OverviewTab.tsx | 5 +- frontend/src/app/admin/events/[id]/_types.ts | 14 +- frontend/src/app/admin/events/[id]/page.tsx | 178 ++++------- frontend/src/app/admin/layout.tsx | 31 ++ frontend/src/app/admin/page.tsx | 31 +- frontend/src/app/admin/scanner/page.tsx | 13 +- frontend/src/app/admin/users/page.tsx | 100 +++++- .../src/components/admin/SensitiveValue.tsx | 22 ++ frontend/src/context/PrivacyContext.tsx | 66 ++++ frontend/src/hooks/useStatsPrivacy.ts | 41 --- frontend/src/i18n/locales/en.json | 4 + frontend/src/i18n/locales/es.json | 4 + frontend/src/lib/api/tickets.ts | 22 +- frontend/src/lib/api/types.ts | 3 + 24 files changed, 748 insertions(+), 676 deletions(-) create mode 100644 frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx create mode 100644 frontend/src/components/admin/SensitiveValue.tsx create mode 100644 frontend/src/context/PrivacyContext.tsx delete mode 100644 frontend/src/hooks/useStatsPrivacy.ts diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index b998a3b..bab2ed9 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -199,7 +199,19 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } - + + // Migration: Add payment_status column to tickets (paid | unpaid | comp), + // backfilled from is_guest and the payments table on first run + try { + await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'unpaid'`); + await (db as any).run(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`); + await (db as any).run(sql` + UPDATE tickets SET payment_status = 'paid' + WHERE is_guest = 0 + AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid') + `); + } catch (e) { /* column may already exist */ } + // Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries) // SQLite doesn't support altering column constraints, so we'll just ensure new entries work @@ -702,6 +714,18 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } + // Migration: Add payment_status column to tickets (paid | unpaid | comp), + // backfilled from is_guest and the payments table on first run + try { + await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN payment_status VARCHAR(10) NOT NULL DEFAULT 'unpaid'`); + await (db as any).execute(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`); + await (db as any).execute(sql` + UPDATE tickets SET payment_status = 'paid' + WHERE is_guest = 0 + AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid') + `); + } catch (e) { /* column may already exist */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS payments ( id UUID PRIMARY KEY, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index ee75c02..fc964f6 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -110,6 +110,8 @@ export const sqliteTickets = sqliteTable('tickets', { qrCode: text('qr_code'), adminNote: text('admin_note'), isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false), + // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue + paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'), createdAt: text('created_at').notNull(), }); @@ -468,6 +470,8 @@ export const pgTickets = pgTable('tickets', { qrCode: varchar('qr_code', { length: 255 }), adminNote: pgText('admin_note'), isGuest: pgInteger('is_guest').notNull().default(0), + // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue + paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'), createdAt: timestamp('created_at').notNull(), }); diff --git a/backend/src/lib/holdRecovery.ts b/backend/src/lib/holdRecovery.ts index 1983430..2888dda 100644 --- a/backend/src/lib/holdRecovery.ts +++ b/backend/src/lib/holdRecovery.ts @@ -75,6 +75,12 @@ export async function reserveOnHoldBooking( if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId; } + // Keep the ticket-level payment flag in sync when the payment settles + const ticketUpdate: Record = { status: targetTicketStatus }; + if (targetPaymentStatus === 'paid') { + ticketUpdate.paymentStatus = 'paid'; + } + // `needed` is how many of these tickets don't currently hold a seat and so must // be found new capacity; tickets already in a seat-holding state cost nothing. const assertCapacity = (reserved: number, needed: number) => { @@ -97,7 +103,7 @@ export async function reserveOnHoldBooking( } tx.update(tickets) - .set({ status: targetTicketStatus }) + .set(ticketUpdate) .where(and( inArray((tickets as any).id, ticketIds), inArray((tickets as any).status, fromTicketStatuses) @@ -118,7 +124,7 @@ export async function reserveOnHoldBooking( } await tx.update(tickets) - .set({ status: targetTicketStatus }) + .set(ticketUpdate) .where(and( inArray((tickets as any).id, ticketIds), inArray((tickets as any).status, fromTicketStatuses) diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index 46efe29..0a33c2c 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -288,7 +288,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) { for (const ticket of ticketsToConfirm) { const result: any = await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: 'confirmed', paymentStatus: 'paid' }) .where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending'))); transitioned += result?.changes ?? result?.rowCount ?? 0; diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index 692ad0d..72ad423 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -288,7 +288,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: 'confirmed', paymentStatus: 'paid' }) .where(eq((tickets as any).id, (t as any).id)); } diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 4c04f92..ff2c0fd 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -361,7 +361,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { for (const t of createdTickets) { await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: 'confirmed', paymentStatus: 'paid' }) .where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending'))); await (db as any) .update(payments) @@ -1002,6 +1002,9 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as attendeeEmail: ticket.attendeeEmail, attendeePhone: ticket.attendeePhone, status: ticket.status, + paymentStatus: ticket.paymentStatus, + // Balance to collect at the door for unpaid tickets + amountDue: ticket.paymentStatus === 'unpaid' && event ? event.price : 0, checkinAt: ticket.checkinAt, checkedInBy, }, @@ -1082,10 +1085,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] return c.json({ error: 'Ticket not found' }, 404); } - if (ticket.status === 'confirmed') { + // Confirmed/checked-in tickets can still be marked paid when they carry an + // unpaid balance (admin-added unpaid tickets collected at the door) + if (['confirmed', 'checked_in'].includes(ticket.status) && ticket.paymentStatus !== 'unpaid') { return c.json({ error: 'Ticket already confirmed' }, 400); } - + if (ticket.status === 'cancelled') { return c.json({ error: 'Cannot confirm cancelled ticket' }, 400); } @@ -1125,12 +1130,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] throw err; } } else { - // Confirm all tickets in the booking + // Confirm all tickets in the booking (checked-in tickets keep their status) for (const t of ticketsToConfirm) { // Update ticket status await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' }) .where(eq((tickets as any).id, t.id)); // Update payment status @@ -1471,6 +1476,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, preferredLanguage: data.preferredLanguage || null, status: ticketStatus, + paymentStatus: 'paid', qrCode, checkinAt: data.autoCheckin ? now : null, adminNote: data.adminNote || null, @@ -1514,148 +1520,26 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) }, 201); }); -// Admin create manual ticket (sends confirmation email + ticket to attendee) -ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ - eventId: z.string(), - firstName: z.string().min(2), - lastName: z.string().optional().or(z.literal('')), - email: z.string().email('Valid email is required for manual tickets'), - phone: z.string().optional().or(z.literal('')), - preferredLanguage: z.enum(['en', 'es']).optional(), - adminNote: z.string().max(1000).optional(), -})), async (c) => { - const data = c.req.valid('json'); - - // Get event - const event = await dbGet( - (db as any).select().from(events).where(eq((events as any).id, data.eventId)) - ); - if (!event) { - return c.json({ error: 'Event not found' }, 404); - } - - // Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets) - - const now = getNow(); - const attendeeEmail = data.email.trim(); - - // Find or create user - let user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) - ); - - const fullName = data.lastName && data.lastName.trim() - ? `${data.firstName} ${data.lastName}`.trim() - : data.firstName; - - if (!user) { - const userId = generateId(); - user = { - id: userId, - email: attendeeEmail, - password: '', - name: fullName, - phone: data.phone || null, - role: 'user', - languagePreference: null, - createdAt: now, - updatedAt: now, - }; - await (db as any).insert(users).values(user); - } - - // Check for existing active ticket for this user and event - const existingTicket = await dbGet( - (db as any) - .select() - .from(tickets) - .where( - and( - eq((tickets as any).userId, user.id), - eq((tickets as any).eventId, data.eventId) - ) - ) - ); - - if (existingTicket && existingTicket.status !== 'cancelled') { - return c.json({ error: 'This person already has a ticket for this event' }, 400); - } - - // Create ticket as confirmed - const ticketId = generateId(); - const qrCode = generateTicketCode(); - - const newTicket = { - id: ticketId, - userId: user.id, - eventId: data.eventId, - attendeeFirstName: data.firstName, - attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, - attendeeEmail: attendeeEmail, - attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, - preferredLanguage: data.preferredLanguage || null, - status: 'confirmed', - qrCode, - checkinAt: null, - adminNote: data.adminNote || null, - createdAt: now, - }; - - await (db as any).insert(tickets).values(newTicket); - - // Create payment record (marked as paid - manual entry) - const paymentId = generateId(); - const adminUser = (c as any).get('user'); - const newPayment = { - id: paymentId, - ticketId, - provider: 'cash', - amount: event.price, - currency: event.currency, - status: 'paid', - reference: 'Manual ticket', - paidAt: now, - paidByAdminId: adminUser?.id || null, - createdAt: now, - updatedAt: now, - }; - - await (db as any).insert(payments).values(newPayment); - - // Send booking confirmation email + ticket (asynchronously) - emailService.sendBookingConfirmation(ticketId).then(result => { - if (result.success) { - console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`); - } else { - console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error); - } - }).catch(err => { - console.error('[Email] Exception sending booking confirmation for manual ticket:', err); - }); - - return c.json({ - ticket: { - ...newTicket, - event: { - title: event.title, - startDatetime: event.startDatetime, - location: event.location, - }, - }, - payment: newPayment, - message: 'Manual ticket created and confirmation email sent', - }, 201); -}); - -// Admin invite guest ticket (free, confirmed, not counted in revenue) -ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ +// Unified admin add-attendee endpoint backing the single Add Ticket modal. +// type drives payment handling: +// paid — email required; paid cash payment; confirmation email + QR sent +// unpaid — QR issued with balance due (collect at door); pending tpago payment; +// pay-link (Bancard/TPago) email sent when an email is provided +// guest — free comp ticket, not counted in revenue; confirmation email only +// when an email is provided +ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ eventId: z.string(), + type: z.enum(['paid', 'unpaid', 'guest']), firstName: z.string().min(1), lastName: z.string().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal('')), phone: z.string().optional().or(z.literal('')), preferredLanguage: z.enum(['en', 'es']).optional(), + checkinNow: z.boolean().optional().default(false), adminNote: z.string().max(1000).optional(), +}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), { + message: 'Email is required for paid tickets', + path: ['email'], })), async (c) => { const data = c.req.valid('json'); @@ -1666,18 +1550,20 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), return c.json({ error: 'Event not found' }, 404); } + // Admin-added tickets bypass the capacity check (intentional over-capacity) + const now = getNow(); const adminUser = (c as any).get('user'); - - // Find or create user (use placeholder email if none provided) - const attendeeEmail = data.email && data.email.trim() - ? data.email.trim() - : `guest-${generateId()}@guestinvite.local`; + const hasEmail = !!(data.email && data.email.trim()); + const attendeeEmail = hasEmail + ? data.email!.trim() + : `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`; const fullName = data.lastName && data.lastName.trim() ? `${data.firstName} ${data.lastName}`.trim() : data.firstName; + // Find or create user let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) ); @@ -1698,8 +1584,8 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), await (db as any).insert(users).values(user); } - // Check for existing active ticket (only for real emails, not placeholder) - if (data.email && data.email.trim()) { + // Check for existing active ticket (only when a real email was provided) + if (hasEmail) { const existingTicket = await dbGet( (db as any) .select() @@ -1718,6 +1604,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), const ticketId = generateId(); const qrCode = generateTicketCode(); + const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid'; const newTicket = { id: ticketId, @@ -1725,50 +1612,85 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), eventId: data.eventId, attendeeFirstName: data.firstName, attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, - attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null, + attendeeEmail: hasEmail ? data.email!.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, preferredLanguage: data.preferredLanguage || null, - status: 'confirmed', - isGuest: 1, + status: data.checkinNow ? 'checked_in' : 'confirmed', + isGuest: data.type === 'guest' ? 1 : 0, + paymentStatus, qrCode, - checkinAt: null, + checkinAt: data.checkinNow ? now : null, + checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null, adminNote: data.adminNote || null, createdAt: now, }; await (db as any).insert(tickets).values(newTicket); - // Create a $0 payment record to track the invite + // Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid const paymentId = generateId(); - const newPayment = { - id: paymentId, - ticketId, - provider: 'cash', - amount: 0, - currency: event.currency, - status: 'paid', - reference: 'Guest invite', - paidAt: now, - paidByAdminId: adminUser?.id || null, - createdAt: now, - updatedAt: now, - }; + const newPayment = data.type === 'unpaid' + ? { + id: paymentId, + ticketId, + provider: 'tpago', + amount: event.price, + currency: event.currency, + status: 'pending', + reference: 'Unpaid ticket — collect at door', + paidAt: null, + paidByAdminId: null, + createdAt: now, + updatedAt: now, + } + : { + id: paymentId, + ticketId, + provider: 'cash', + amount: data.type === 'guest' ? 0 : event.price, + currency: event.currency, + status: 'paid', + reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket', + paidAt: now, + paidByAdminId: adminUser?.id || null, + createdAt: now, + updatedAt: now, + }; await (db as any).insert(payments).values(newPayment); - // Send booking confirmation email if a real email was provided - if (data.email && data.email.trim()) { + // Emails (asynchronous): paid always confirms; guest confirms when an email + // exists; unpaid sends the TPago (Bancard) pay-link instructions instead + if (data.type === 'unpaid') { + if (hasEmail) { + emailService.sendPaymentInstructions(ticketId).then(result => { + if (!result.success) { + console.error(`[Email] Failed to send pay link for unpaid ticket ${ticketId}:`, result.error); + } + }).catch(err => { + console.error('[Email] Exception sending pay link for unpaid ticket:', err); + }); + } + } else if (data.type === 'paid' || hasEmail) { emailService.sendBookingConfirmation(ticketId).then(result => { - if (result.success) { - console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`); - } else { - console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error); + if (!result.success) { + console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error); } }).catch(err => { - console.error('[Email] Exception sending booking confirmation for guest ticket:', err); + console.error(`[Email] Exception sending booking confirmation for ${data.type} ticket:`, err); }); } + const messages: Record = { + paid: 'Ticket created — confirmation email sent', + unpaid: hasEmail + ? 'Unpaid ticket created — payment link sent' + : 'Unpaid ticket created — collect payment at the door', + guest: hasEmail + ? 'Guest invited — confirmation email sent' + : 'Guest invited', + }; + return c.json({ ticket: { ...newTicket, @@ -1779,7 +1701,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), }, }, payment: newPayment, - message: 'Guest ticket created successfully', + message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type], }, 201); }); diff --git a/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx b/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx index a410096..e4be1db 100644 --- a/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx +++ b/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx @@ -18,3 +18,23 @@ export function StatusBadge({ status, compact = false }: { status: string; compa ); } + +// Ticket payment status: Paid (revenue), Unpaid (balance due at door), Comp (free guest). +// Tickets created before the payment_status column default to unpaid via backfill. +export function PaymentBadge({ paymentStatus, compact = false }: { paymentStatus?: string; compact?: boolean }) { + if (!paymentStatus) return null; + const styles: Record = { + paid: 'bg-emerald-100 text-emerald-700', + unpaid: 'bg-orange-100 text-orange-700', + comp: 'bg-amber-100 text-amber-700', + }; + return ( + + {paymentStatus === 'comp' ? 'Comp' : paymentStatus === 'unpaid' ? 'Unpaid' : 'Paid'} + + ); +} diff --git a/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx b/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx new file mode 100644 index 0000000..d971606 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx @@ -0,0 +1,209 @@ +import type { Dispatch, FormEvent, SetStateAction } from 'react'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import clsx from 'clsx'; +import { + BanknotesIcon, + CheckCircleIcon, + EnvelopeIcon, + LinkIcon, + StarIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import type { AddTicketType, AddTicketFormState } from '../_types'; + +interface AddTicketModalProps { + open: boolean; + onClose: () => void; + form: AddTicketFormState; + setForm: Dispatch>; + onSubmit: (e: FormEvent) => void; + submitting: boolean; + eventPriceLabel: string; +} + +const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [ + { value: 'paid', label: 'Paid' }, + { value: 'unpaid', label: 'Unpaid' }, + { value: 'guest', label: 'Guest' }, +]; + +const SUBMIT_LABELS: Record = { + paid: 'Create & send ticket', + unpaid: 'Create & send pay link', + guest: 'Invite guest', +}; + +const SUBMIT_ICONS: Record = { + paid: EnvelopeIcon, + unpaid: LinkIcon, + guest: StarIcon, +}; + +// Live "what happens" preview lines for the selected type / email / check-in combo +function previewLines(form: AddTicketFormState, eventPriceLabel: string): string[] { + const hasEmail = !!form.email.trim(); + const lines: string[] = []; + if (form.type === 'paid') { + lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`); + lines.push('Confirmation email with QR ticket sent'); + } else if (form.type === 'unpaid') { + lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`); + lines.push('QR code issued, flagged "unpaid" for door staff'); + lines.push(hasEmail + ? 'Bancard (TPago) payment link emailed to the attendee' + : 'No email — no pay link sent, payment collected at the door'); + } else { + lines.push('Free guest ticket (comp) — not counted in revenue'); + lines.push('Auto-confirmed with QR code'); + lines.push(hasEmail ? 'Confirmation email sent' : 'No email — nothing is sent'); + } + if (form.checkinNow) { + lines.push('Checked in immediately'); + } + return lines; +} + +const PREVIEW_STYLES: Record = { + paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' }, + unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' }, + guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' }, +}; + +const PREVIEW_ICONS: Record = { + paid: CheckCircleIcon, + unpaid: BanknotesIcon, + guest: StarIcon, +}; + +export function AddTicketModal({ + open, + onClose, + form, + setForm, + onSubmit, + submitting, + eventPriceLabel, +}: AddTicketModalProps) { + if (!open) return null; + + const emailRequired = form.type === 'paid'; + const style = PREVIEW_STYLES[form.type]; + const PreviewIcon = PREVIEW_ICONS[form.type]; + const SubmitIcon = SUBMIT_ICONS[form.type]; + + return ( +
+ e.stopPropagation()} + > +
+

Add Ticket

+ +
+
+ {/* Segmented ticket-type control */} +
+ {TYPE_OPTIONS.map((option) => ( + + ))} +
+ +
+
+ + setForm((f) => ({ ...f, firstName: e.target.value }))} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="First name" /> +
+
+ + setForm((f) => ({ ...f, lastName: e.target.value }))} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="Last name" /> +
+
+
+ + setForm((f) => ({ ...f, email: e.target.value }))} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} /> +

+ {form.type === 'paid' && 'Ticket will be sent to this email'} + {form.type === 'unpaid' && 'If provided, the payment link is sent here'} + {form.type === 'guest' && 'If provided, a confirmation email will be sent'} +

+
+
+ + setForm((f) => ({ ...f, phone: e.target.value }))} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="+595 981 123456" /> +
+
+ +