quill-backend) and a Next.js 16 App Router frontend (quill-frontend). The API owns all product state in a single Postgres database via Drizzle ORM over postgres.js; the frontend is a thin rendering layer that reaches the API only through a same-origin proxy.
The whole system
Everything under the API is one process. Unlike Duro’s three-service split, Quill is a single NestJS app: it is the merchant, not the processor, so it has one trust boundary and one runtime shape. The heavy lifting that would justify a separate worker (renewals, dunning) lives inside Duro, not Quill.The NestJS module map
AppModule wires a ConfigModule (global), a ThrottlerModule (120 req / 60s), a BullModule bound to Redis, and the feature modules. Three global guards and one global interceptor run on every request. Source: src/app.module.ts.
DuroModule is @Global, so any service can inject DuroService without importing the module. It is the single seam between Quill and Duro (src/duro/duro.module.ts).
Request pipeline
Every request passes the same short chain, defined once inAppModule providers and main.ts:
- Global prefix
api/v1is set inmain.ts, so every route is/api/v1/.... - Auth is a JWT carried in an httpOnly cookie named
quill_session(aBearerheader is also accepted). The guard extracts and verifies it, attaching{ id, email, role }to the request.@Public()opts a route out (used by the webhook receiver). Source:src/common/guards/jwt-auth.guard.ts. - Response envelope:
ResponseInterceptorwraps every successful body as{ success: true, data };HttpExceptionFiltershapes errors. This is the same envelope the frontend’s axios client unwraps.
The Drizzle schema
Schema is plain DrizzlepgTable definitions under src/database/schema/, composed by schema/index.ts and handed to drizzle(postgres(url, { max: 10 })) in a @Global DatabaseModule. Primary keys are prefixed, collision-resistant ids (newId('sub') → sub_<21-char nanoid>), generated in src/common/id.ts.
Tables that carry the money integration:
subscriptionslinks asubscriberIdandpublicationIdand mirrors Duro state:status(apgEnum:pending | active | past_due | paused | canceled | expired), plusduroSubscriptionId,duroCustomerId,duroCheckoutId,priceKobo,currentPeriodStart/End. Source:src/database/schema/subscriptions.ts.ledger_entriesis an append-only revenue log (type:subscription_charge | payout | refund | adjustment,amountKobo,description,reference). Quill records a charge here on activation; recovery charges are taggeddescription = 'Subscription recovery'so recovery revenue is a filteredSUM.publicationscachesduroPlanIdso the Duro plan is created once per publication, then reused.payouts/payout_accounts/bill_transactionsback the money-out and VAS flows (see Payouts & VAS).webhook_eventsstores every received Duro event keyed by a uniqueeventIdfor idempotent processing.
priceKobo, amountKobo); src/common/money.ts holds the PLATFORM_FEE_BPS = 1000 (10%) fee helpers and formatNaira.
The same-origin BFF frontend
The Next.js app (quill-frontend) follows the Guild/Duro BFF pattern: the browser only ever calls its own origin.
- Client (
lib/api/client.ts) is an axios instance withbaseURL: '/api'andwithCredentials: true; it unwraps the{ success, data }envelope and normalises errors to anApiError. - Proxy (
app/api/[...path]/route.ts) forwardsGET/POST/PATCH/PUT/DELETEtoBACKEND_API_URL(defaulthttp://localhost:7010/api/v1), copying through thecookie,content-type,x-forwarded-for, anduser-agentheaders, and relayingSet-Cookieback withgetSetCookie(). It isruntime = "nodejs"anddynamic = "force-dynamic". - Data layer is TanStack Query (
@tanstack/react-query) over that client; the editor is TipTap; premium video plays viahls.js. - Uploads are the one exception that does not proxy to Nest:
app/api/upload/route.tschecks the session against the backend, then writes the image straight to R2 (5 MB cap, image types only).
next dev --port 7013); the backend on 7010.
BullMQ workers and R2 media
Two queues run in-process via@nestjs/bullmq WorkerHost processors:
emails
EmailService.deliver() enqueues a send job (3 attempts, exponential backoff); EmailProcessor calls nodemailer. Without SMTP_HOST it logs instead of sending. Templates are inlined HTML with a “powered by Duro” footer. Source: src/email/*.video-transcode
On upload,
VideosService.create() enqueues a transcode job (2 attempts). TranscodeService shells out to ffmpeg-static: it produces AES-128 encrypted HLS (-hls_key_info_file), a short unencrypted preview, and a thumbnail, then uploads segments to R2. Source: src/videos/*.GET .../key) to a user with an active subscription to that publication; non-subscribers get the preview playlist. R2 access uses @aws-sdk/client-s3 against the R2 S3 endpoint (src/storage/storage.service.ts).
Next: Duro Integration, the seam where all of this meets Duro’s money layer.