Architecture
Overview
Wairo is a pnpm + Turborepo monorepo containing three Fastify APIs, two Next.js 15 frontends, and three shared packages.
wairo/
├── 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 constants
└── email Resend wrapper + React Email templatesRule: 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
@repo/email
sendEmail({ to, subject, react, replyTo? })— renders a React Email component and sends it via Resend. No-ops whenRESEND_API_KEYis not set; throws on Resend errors so BullMQ retries the job.- Eight ready-made React Email templates (see Email Templates)
FROM_EMAILconstant — defaults tono-reply@wairo.app, overridable viaRESEND_FROM_EMAIL- Used exclusively by
media-apinotification workers andbackoffice-apisupport handler; never called from route handlers directly
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
→ generate_thumbnail completes → enqueues cluster_faces for each project member
→ Workers process asynchronouslyFace Clustering
User uploads selfie (main-web)
→ POST /v1/selfie/upload-url (media-api, requires biometric consent)
→ Browser PUTs selfie to R2
→ BullMQ: process_selfie
→ Calls Retina service (Google Cloud Run) with selfie image
→ Stores 512-dim embedding; face detection score >= 0.9 → approved
→ Enqueues cluster_faces for each of the user's project memberships
User joins project via QR
→ POST /v1/qr/join (main-api)
→ Enqueues cluster_faces for that user + project
BullMQ: cluster_faces (payload: { userId, projectId })
→ Sends selfie + all project thumbnails to Retina service (50-image chunks)
→ Photos sharing the selfie's cluster_id → photoTag confirmed
→ All matches are confirmed (binary cluster membership — no pending queue)Purchase
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) |
| Google Cloud Run (Retina) | Stateless face clustering service |
| Google Maps Platform | Location autocomplete (Places API New) and timezone auto-detection (Time Zone API) in the backoffice |
| Resend | Transactional email |
| Stripe | Payment processing |