Architecture
Overview
Acme Photo Platform is a pnpm + Turborepo monorepo containing three Fastify APIs, two Next.js 15 frontends, and three shared packages.
acme-photo-platform/
├── apps/
│ ├── backoffice-api Fastify — operators, photographers, analytics
│ ├── main-api Fastify — end users, auth, purchases, QR join
│ ├── media-api Fastify — uploads, watermarking, face recognition
│ ├── backoffice-web Next.js 15 — operator & photographer dashboard
│ └── main-web Next.js 15 — end user app
└── packages/
├── db Drizzle ORM schema, migrations, db client
├── auth JWT sign/verify, bcrypt, Fastify middleware
└── config Zod-validated env schema, job name constantsRule: apps import from packages, never from other apps.
Apps
backoffice-api (port 3001)
Serves operators and photographers. Handles project management, package pricing, photographer assignment, and analytics.
JWT audience: backoffice
main-api (port 3002)
Serves end users. Handles registration, QR-based project joining, biometric consent, photo browsing, and Stripe purchases.
JWT audience: main
media-api (port 3003)
Handles all file operations. Issues presigned R2 URLs for uploads, and runs BullMQ workers for thumbnail generation, watermarking, and face recognition.
JWT audience: media (for end users) and backoffice (for operator/photographer uploads)
backoffice-web (port 4001)
Next.js dashboard for operators (project/package/photographer management, analytics) and photographers (assigned projects, photo upload).
main-web (port 4002)
Next.js app for end users: registration, biometric consent, QR project joining, selfie upload, photo gallery, and purchases.
Shared Packages
@repo/db
- Drizzle ORM schema — one file per domain entity in
src/schema/ - Postgres client (
drizzle-orm/postgres-js) - Exports all schema tables, types, and Drizzle query helpers
@repo/auth
signToken/verifyToken— JWT with audience validationhashPassword/verifyPassword— bcryptauthenticate— Fastify plugin that validates Bearer tokens and attachesrequest.userrequireRole(...roles)— preHandler factory for role-based access control
@repo/config
- Zod-validated
envobject — imported instead of readingprocess.envdirectly JobNameconst enum — all BullMQ job names live here; never hardcode strings
Data Flow
Photo Upload
Photographer (backoffice-web)
→ POST /v1/photographers/me/photos/upload-url (media-api)
→ DB: creates photo record, returns presigned PUT URL
→ Browser PUTs file directly to Cloudflare R2
→ BullMQ enqueues: generate_thumbnail, generate_watermark, recognize_faces
→ Workers process asynchronouslyFace Recognition
User uploads selfie (main-web)
→ POST /v1/selfie/upload-url (media-api, requires biometric consent)
→ Browser PUTs selfie to R2
→ BullMQ: process_selfie
→ AWS Rekognition IndexFaces (per-user collection)
→ selfie.status = 'approved'
→ On each photo upload: BullMQ: recognize_faces
→ Rekognition SearchFacesByImage against project collection
→ confidence >= 75 → photoTag confirmed
→ confidence 55–74 → photoTag pending (manual review)
→ confidence < 55 → discardedPurchase
User selects package (main-web)
→ POST /v1/purchases (main-api)
→ Stripe PaymentIntent created
→ Stripe webhook confirms payment
→ purchase.status = 'paid'
→ User can download watermarked photosAuth Model
Three separate JWT audiences prevent token cross-use:
| Audience | Used by | Issued by |
|---|---|---|
backoffice | Operators, photographers | backoffice-api |
main | End users | main-api |
media | End users (selfie upload) | main-api |
Roles: operator, photographer, user, superadmin
Infrastructure
| Service | Purpose |
|---|---|
| PostgreSQL + pgvector | Primary database, face embeddings |
| Redis | BullMQ job queue backend |
| Cloudflare R2 | Object storage (photos, selfies, thumbnails) |
| AWS Rekognition | Face indexing and search |
| Resend | Transactional email |
| Stripe | Payment processing |