Architecture
One Next.js app, one process, one SQLite file, one folder of photos.
┌────────────────────── Guestroll (Node.js) ───────────────────────┐
phone ──QR code──▶ │ /e/[slug] guest page (server component + client app) │
│ /api/e/[slug]/* join, unlock, name, upload, own photos │
│ /api/media/[id] the only way a photo leaves the server │
laptop ──────────▶ │ /admin/* admin pages │
│ /api/admin/* events, moderation, QR, archives, stats │
│ │
│ lib/policy.ts ◀── every access decision, pure and tested │
│ lib/* ──▶ SQLite (Drizzle) ──▶ DATA_DIR/guestroll.db │
│ ──▶ files ──▶ DATA_DIR/uploads/ │
└──────────────────────────────────────────────────────────────────┘Request flow: a guest takes a photo
- The QR code opens
/e/<slug>. The server component looks the event up; if the browser hasn't joined it yet, it rendersAutoJoin(no code) orUnlockScreen(code). Joining writes the event id into the encryptedgr_guestcookie. - The guest page renders the capture component chosen for the event (
components/capture/registry.ts) insideCaptureHost. - The capture component calls
onFiles(files).useUploadQueuecompresses each file in the browser, then uploads it with progress, one at a time, retrying on network errors and waiting while offline. POST /api/e/<slug>/photoschecks the origin, the join, the upload state and quota (lib/policy.ts), takes a per-guest lock, reads the body with a size cap, sniffs and re-encodes the image (lib/images/process.ts) into three variants, writes them underDATA_DIR/uploads/<event>/and inserts the row.- The photo comes back as a
PhotoDto: ids and dimensions, never a path. Thumbnails load from/api/media/<id>?v=thumb, which re-checks access.
Code layout
src/
├── app/ routes (Next.js App Router)
│ ├── e/[slug]/ guest page and its social card
│ ├── admin/ login, (panel)/ pages, poster, live wall
│ └── api/ route handlers, thin: validate, ask policy, call lib
├── components/
│ ├── ui/ design-system primitives (Button, Field, SegmentedControl…)
│ ├── capture/ swappable capture components and their contract
│ ├── upload/ CaptureHost, upload queue, exposure counter, prints tray
│ ├── guest/ guest page pieces
│ └── admin/ admin pieces (event-form/, moderation/, qr/…)
└── lib/
├── policy.ts all access rules, no I/O
├── validation.ts request body parsing, no I/O
├── db/ Drizzle schema and client (migrations in /drizzle)
├── auth/ admin password, sessions, page guard
├── security/ rate limiter, request helpers, ids
├── images/ upload and logo processing (sharp)
├── storage/ file paths and I/O
├── qr/ style parsing, the SVG renderer, server export
├── i18n/ messages (en, fr, it, es), locale resolution
└── events.ts, photos.ts, stats.ts, archive.ts data accessTwo rules keep this maintainable:
- Access rules live in
lib/policy.tsonly. Routes ask it; they never re-derive a rule inline. The SQL filter inlistPhotosmirrorscanViewPhotoand is the one exception, commented as such. - Browser code never imports server modules. Anything shared with client components lives in modules without Node imports (
lib/constants.ts,lib/qr/render.ts,lib/i18n/messages.ts). Server-only modules importserver-onlyso a mistake fails the build.
Data
| Table | Holds |
|---|---|
events | Settings, access-code hash, QR style (JSON), logo version |
photos | One row per photo: event, guest id, optional name, size, status, download count |
activity | Append-only log behind every stat (view, upload, download, failed code…) |
settings | Key/value: admin password hash and its version, SMTP (password sealed), alert choices |
share_links | Gallery links: token, password hash, expiry, downloads switch, views, revocation |
guest_contacts | Opt-in guest emails, one per guest and event, deleted with the event |
outbox | Email queue: rendered message, state, attempts, next retry, dedupe key |
Guests have no table: a guest is a random id in their cookie. Their name is copied onto each photo they send.
Files: uploads/<event-id>/<photo-id>.{original.jpg,display.webp,thumb.webp} and uploads/<event-id>/logo.png.
Choices worth knowing
Email goes through a queue in SQLite, drained by an in-process scheduler (
src/lib/scheduler.ts, started frominstrumentation.ts). An SMTP outage never fails a request, and each email has a dedupe key so a retried job can't send it twice. Ending an event is claimed atomically onended_at.SQLite in WAL mode handles a wedding's burst of uploads comfortably and makes backups a file copy. The flip side: one process, no horizontal scaling. That's also why the rate limiter is in memory.
Re-encoding every upload costs CPU but removes GPS metadata and any payload hidden in the file.
One upload at a time per guest, enforced on both sides, keeps quotas exact and phones responsive.
The QR renderer is pure and shared by the live preview and the server export, so the downloaded file matches the preview.