Skip to main content
Quill is two deployables: a NestJS 11 API (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 in AppModule providers and main.ts:
  • Global prefix api/v1 is set in main.ts, so every route is /api/v1/....
  • Auth is a JWT carried in an httpOnly cookie named quill_session (a Bearer header 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: ResponseInterceptor wraps every successful body as { success: true, data }; HttpExceptionFilter shapes errors. This is the same envelope the frontend’s axios client unwraps.

The Drizzle schema

Schema is plain Drizzle pgTable 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:
  • subscriptions links a subscriberId and publicationId and mirrors Duro state: status (a pgEnum: pending | active | past_due | paused | canceled | expired), plus duroSubscriptionId, duroCustomerId, duroCheckoutId, priceKobo, currentPeriodStart/End. Source: src/database/schema/subscriptions.ts.
  • ledger_entries is an append-only revenue log (type: subscription_charge | payout | refund | adjustment, amountKobo, description, reference). Quill records a charge here on activation; recovery charges are tagged description = 'Subscription recovery' so recovery revenue is a filtered SUM.
  • publications caches duroPlanId so the Duro plan is created once per publication, then reused.
  • payouts / payout_accounts / bill_transactions back the money-out and VAS flows (see Payouts & VAS).
  • webhook_events stores every received Duro event keyed by a unique eventId for idempotent processing.
All amounts are integer kobo end to end (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 with baseURL: '/api' and withCredentials: true; it unwraps the { success, data } envelope and normalises errors to an ApiError.
  • Proxy (app/api/[...path]/route.ts) forwards GET/POST/PATCH/PUT/DELETE to BACKEND_API_URL (default http://localhost:7010/api/v1), copying through the cookie, content-type, x-forwarded-for, and user-agent headers, and relaying Set-Cookie back with getSetCookie(). It is runtime = "nodejs" and dynamic = "force-dynamic".
  • Data layer is TanStack Query (@tanstack/react-query) over that client; the editor is TipTap; premium video plays via hls.js.
  • Uploads are the one exception that does not proxy to Nest: app/api/upload/route.ts checks the session against the backend, then writes the image straight to R2 (5 MB cap, image types only).
The frontend runs on port 7013 in dev (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/*.
Premium video is gated at the key: the HLS encryption key is only served (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.