ADR: Long-lived signed token + PDF ticket via email (X1)
ADR: Long-lived signed token + PDF ticket via email (X1)
Section titled “ADR: Long-lived signed token + PDF ticket via email (X1)”Context
Section titled “Context”Two prior tickets shipped the customer-ticket-verify foundation:
- #14a (
booking-verify-token, merged 2026-06-18) introducedBookingVerifyTokenService(libs/shared/booking-verify-token/src/lib/booking-verify-token.service.ts) with HMAC-SHA256 sign/verify. The public API issign({ bookingId, ttlSec? })/verify(token)— note: the spec calls thisgenerateTokenbut the actual method name in code issign. Default TTL is 30s (booking-verify-token.constants.ts:16), targeted at the in-app QR refresh flow. - #14b (scanner ecosystem, merged 2026-06-19) wired
POST /api/scanner/bookings/verify(bookings-scanner.controller.ts:49, delegating toBookingsVerifyServiceatlibs/features/activities/src/lib/services/bookings-verify.service.ts:69). The verify path enforces HMAC +exp+ booking eligibility but does not enforce any TTL ceiling — any signed, non-expired token is accepted.
This ticket (X1, ClickUp 869dt9bxd) absorbs the cancelled backlog ticket
869dt29uq (printable PDF with long-lived QR). It adds:
- A long-lived variant of the verify token, with
expderived from session timing (session.endsAt + graceorsession.startsAt + grace). - A
BookingTicketPdfServicethat renders an A4 PDF (company logo, activity, session date/time, customer name, QR encoding the long-lived token, booking ID, entry-instructions string). - Wiring of the PDF email on every
PENDING_PAYMENT → CONFIRMEDpath (direct CONFIRMED booking, LiqPay webhook, Monobank webhook, DEFER settle, wallet/pay), via the existing BullMQnotifications-emailqueue.
It is intentionally pure backend — no consumer codegen, no UI changes, no
new HTTP endpoints. X2 (869dt9c0x, guest checkout via email) blocks on X1
and reuses the same PDF service + long-lived token variant.
Concrete code anchors verified during investigation
Section titled “Concrete code anchors verified during investigation”| Concern | File / line |
|---|---|
| Token service (sign / verify) | libs/shared/booking-verify-token/src/lib/booking-verify-token.service.ts:129 |
| Token signing secret env | BOOKING_VERIFY_SIGNING_SECRET (read in libs/features/activities/src/lib/activities-client.module.ts:40 and libs/features/activities/src/lib/bookings-scanner.module.ts:22) |
| Scanner verify endpoint | libs/features/activities/src/lib/controllers/bookings-scanner.controller.ts:49 — path POST /api/scanner/bookings/verify |
| Scanner verify service (no TTL ceiling) | libs/features/activities/src/lib/services/bookings-verify.service.ts:69 |
| Email channel (Resend) | libs/shared/messaging/src/lib/channels/email/email.channel.ts:7 |
| Email payload interface | libs/shared/messaging/src/lib/interfaces/notification-channel.interface.ts:1 — SendEmailPayload has no attachments field today |
| Email queue + processor | libs/features/notifications/src/lib/processors/email.processor.ts:6 (concurrency 10) |
| Email producer | libs/shared/messaging/src/lib/messaging.service.ts:74 (jobId dedup by minute + payload hash; jobId is ${recipients}|${subject}|${html} + minute, not ${bookingId}:${type} — see D7 retry-safety) |
| HTML template | libs/shared/messaging/src/lib/channels/email/email.templates.ts:146 (EmailTemplates.notification) |
| Notification fan-out (user-pref check) | libs/features/notifications/src/lib/services/notification-feature.service.ts:77 — emailEnabled defaults to false (line 196) |
| BOOKING_CREATED trigger | libs/features/activities/src/lib/services/bookings-client.service.ts:169 (fireBookingNotifications) |
Direct CONFIRMED creation (bookWithWallet, bookWithBonus) | libs/features/activities/src/lib/services/bookings-client.service.ts:113,153 |
Wallet /pay confirmation | libs/features/activities/src/lib/services/bookings-client.service.ts:457 (payBookingFromWallet) — sets status: 'CONFIRMED' at line 492 |
| LiqPay webhook → CONFIRMED | libs/features/payments/src/lib/services/payments.service.ts:171 |
| Monobank webhook → CONFIRMED | libs/features/payments/src/lib/services/payments.service.ts:312-317 |
| DEFER / admin settle | libs/features/activities/src/lib/services/bookings.service.ts:203 (settle, called from POST /api/business/bookings/:id/settle at bookings-admin.controller.ts:57) |
| Sessions table | libs/shared/data-access-db/src/lib/schema/activities.schema.ts:354 — startsAt + endsAt both NOT NULL; columns are timestamp (naked wall-clock, no withTimezone) |
| Locations table | libs/shared/data-access-db/src/lib/schema/activities.schema.ts:177-187 — NO timezone / tz column |
| Companies table | libs/shared/data-access-db/src/lib/schema/companies.schema.ts:43 — has logoUrl, no accentColor, no defaultLocale |
| Customer email (for AC-16) | libs/shared/data-access-db/src/lib/schema/companies.schema.ts:122 (companyCustomers.email) |
| User language preference | libs/shared/data-access-db/src/lib/schema/users.schema.ts:47 — userProfiles.language |
| i18n loader | libs/shared/common/src/i18n/flat-json.loader.ts; messages at apps/api/src/i18n/{en,uk,ru,de,fr}.json; uses nestjs-i18n |
Resend from default | libs/shared/messaging/src/lib/channels/email/email.channel.ts:17 — `process.env[‘RESEND_FROM’] |
Three findings from the investigation that the spec did NOT account for:
- Monobank is a second payment provider running the same
PENDING_PAYMENT → CONFIRMEDtransition (payments.service.ts:312-317). The spec only names LiqPay; the PDF dispatch hook must cover Monobank too. Otherwise customers paying via Monobank get no PDF. See “Spec vs ADR” note in D7. - No
accentColor, nocompanyDefaultLocale, nosession.timezone, nolocations.timezonecolumns exist. The PDF must fall back to a hardcoded brand colour and a “render-as-stored” timestamp format. Adding these columns is out of scope for X1 and tracked as follow-ups. sessions.startsAt/endsAtaretimestamp(NOTtimestamptz) — they store naked wall-clock values without TZ info. There is also nolocations.timezone. So the PDF spec phrase “session timezone” has no data backing today. See “Session date/time rendering” decision in D5.
Decision
Section titled “Decision”Ship X1 as a backend-only change layered on top of the existing
BookingVerifyTokenService, MessagingService, and EmailProcessor.
Add three new things:
- A
BookingTicketPdfService(new file underlibs/shared/booking-ticket-pdf/src/lib/booking-ticket-pdf.service.ts) that takes already-loaded data (booking, session, activity, customer, company, longLivedToken, locale, entryInstructions) and returns aBuffer— pure rendering, no DB access. Lives inlibs/shared/to match the existinglibs/shared/booking-verify-token/pattern (pure helper, no DB) and to avoid thenotifications → activitiesimport cycle. - A
dispatchConfirmedBookingTicket(booking, session)helper — slim producer that the FIVE confirmation paths (direct-CONFIRMED at create, wallet/pay, LiqPay webhook, Monobank webhook, DEFER admin settle) call. The helper resolves recipient + locale, enforces the AC-12 skip rule, and enqueues a slim BullMQ job payload carrying only{ kind: 'BOOKING_TICKET', bookingId, sessionId, to, locale }— no rendered PDF in the producer. - A PDF-rendering branch in
EmailProcessor.processthat activates whenjob.data.kind === 'BOOKING_TICKET'. The worker fetches booking- session + activity + company + customer, calls
BookingTicketPdfService.render, attaches the resulting Buffer (via the extendedSendEmailPayload.attachmentsfield), and callsEmailChannel.sendEmail.
- session + activity + company + customer, calls
The PDF Buffer is generated inside the BullMQ worker only, never on the HTTP hot path (AC-14). The producer enqueues a slim payload; the worker does the rendering. See D3, D7, D10 for the matching pseudo-code on producer vs. worker sides — they are deliberately separated.
Long-lived tokens reuse the existing payload { bid, iat, exp } (D4 —
no schema change to the token). Scanner verify works unchanged (D8 —
no code change in BookingsVerifyService).
The numbered decisions D1–D10 below ground each acceptance criterion.
Decisions
Section titled “Decisions”D1 — PDF generator: pdfkit
Section titled “D1 — PDF generator: pdfkit”Decision. Use pdfkit (latest, currently ^0.15.0) as the PDF
generator. Add @types/pdfkit as a dev dependency. Node engine in
package.json already requires >=22.12.0 which is well above
pdfkit’s minimum (>=14); no toolchain bump is needed.
Why. pdfkit is the pragmatic minimum for a single-page A4 ticket:
- Pure JS, no native binary, no Chromium runtime → trivial to deploy on the current Node container.
- Streams to a Buffer (
doc.end()+ on(‘end’)) so we never write to disk — the Buffer goes straight into the Resend attachment. - Already battle-tested in many production-grade Node ticketing/receipt flows; the API is stable and small (we use maybe 10 of its methods).
Considered alternatives.
- Puppeteer (headless Chromium) — far more flexible (full HTML/CSS rendering), but ships a 250 MB+ Chromium binary, doubles container memory budget, and adds a 1–3 s cold-start per render. Overkill for a one-page QR ticket and a real ops risk under the BullMQ concurrency cap of 10.
- html-pdf-node / wkhtmltopdf-wrapped — also requires a
binary (
wkhtmltopdf), and the npm wrapper is unmaintained. - PDFLib (
pdf-lib) — better for modifying existing PDFs, weaker primitives for layout-from-scratch (no rich text-flow helpers). pdfkit wins on ergonomics for a from-scratch render.
Grounding. AC-1 / AC-2 / AC-3 / AC-4 — the spec locks pdfkit.
Confirmed Node engine at tktspace-backend/package.json:6-7
("node": ">=22.12.0").
D2 — QR code library: qrcode
Section titled “D2 — QR code library: qrcode”Decision. Use the qrcode npm package, add @types/qrcode.
Generate the QR as a PNG buffer via QRCode.toBuffer(longLivedToken, { type: 'png', errorCorrectionLevel: 'M', margin: 1, width: 300 })
and doc.image(buffer, x, y, { width: 200 }) into the PDF.
Why. Pure JS, no native deps, the de facto Node QR library.
Error-correction level M is the same one #14a uses for in-app QR
display (good balance of density vs scan robustness). PNG (rather
than SVG) embeds straight into a pdfkit Buffer without needing a
vector → raster step.
Considered alternatives.
qr-image— older, lower-quality output, less active.- Native canvas + a hand-rolled QR encoder — silly.
Grounding. Spec AC-1 locks qrcode.
D3 — Email attachment delivery + payload shape (producer vs worker split)
Section titled “D3 — Email attachment delivery + payload shape (producer vs worker split)”Decision. Two distinct payload shapes — one on the queue (slim, no rendered PDF), one passed to the email channel inside the worker (rich, carries the rendered Buffer as base64).
On the BullMQ queue (producer → worker). The slim job payload is the only thing the producer enqueues. No PDF bytes here.
// Discriminated union extension to the email queue job shape.export type EmailJobPayload = | (SendEmailPayload & { kind?: 'PLAIN' }) // existing usage | { // NEW kind: 'BOOKING_TICKET'; bookingId: string; sessionId: string; to: string; // recipient resolved at producer time locale: 'en' | 'uk' | 'ru' | 'de' | 'fr'; };The producer-side helper (D7) enqueues only the BOOKING_TICKET
variant. No attachments, no base64 PDF, no html body on the
queue payload — those are all built by the worker.
Inside the worker (after rendering). The worker assembles the
SendEmailPayload that goes to EmailChannel.sendEmail. This is
where the rendered PDF buffer lives, base64-encoded.
export interface SendEmailAttachment { filename: string; // e.g. "ticket-<bookingId>.pdf" contentBase64: string; // base64-encoded body contentType?: string; // default "application/pdf"}
export interface SendEmailPayload { to: string | string[]; subject: string; html: string; from?: string; replyTo?: string; attachments?: SendEmailAttachment[]; // NEW}EmailChannel.sendEmail is extended to forward attachments to Resend
via Buffer.from(contentBase64, 'base64'). Resend’s SDK
(resend@6.12.3) accepts attachments: [{filename, content: string | Buffer, content_type?}].
Why split the shapes.
- AC-14 forbids PDF rendering on the HTTP hot path. The producer must be cheap and synchronous (one DB read for skip-rule, one queue enqueue). No rendering. No fetching. No base64.
- Keeping the rendered PDF off the queue eliminates the BullMQ JSON payload bloat (a 200 KB PDF becomes ~270 KB base64, multiplied by the number of retries the job survives — wasteful).
- The producer can never accidentally render. The discriminator
kind: 'BOOKING_TICKET'is the only thing the producer sets; rendering literally cannot happen on the producer side because the producer has no PDF service injected.
Considered alternatives.
- Render in HTTP handler, attach Buffer to payload — blocks the request on pdfkit; pdfkit allocates a fair amount of heap on each render; a burst of confirmations would push P99 well past the 10 ms budget. Rejected by AC-14.
- Resend’s
path:field with a presigned URL — would require uploading the PDF to R2 first, then letting Resend fetch it; adds a second network hop and a storage lifecycle problem for short- lived ticket PDFs. Rejected.
D4 — Long-lived token shape: NO new field
Section titled “D4 — Long-lived token shape: NO new field”Decision. The long-lived token uses the exact same payload shape
{ bid, iat, exp } (booking-verify-token.service.ts:58-65) as the
30s in-app token. No kind: 'long' | 'short' field, no separate
key, no separate header version.
Why.
- The scanner verify path (
bookings-verify.service.ts:69) does not inspect any token claim other thanbidandexp. Adding akindfield would change the wire format without giving the scanner any more information thanexpalready conveys. - Shared HMAC key means a single rotation point. A
kindfield would not stop an attacker who got hold of the key — token segregation by claim is security theatre when the threat model is “secret leaks”. - Keeps #14a’s unit and e2e tests valid as-is.
Method shape. We DO add a new public method to
BookingVerifyTokenService so callers don’t have to do session-
to-ttl math at every site:
generateLongLivedToken(input: { bookingId: string; sessionStartsAt: Date; sessionEndsAt: Date | null; graceAfterEndMin: number; // injected via constructor opts (D6) graceFromStartMin: number;}): { token: string; iat: number; exp: number };Internally it just computes ttlSec from session timing then calls
this.sign({ bookingId, ttlSec }). Pure-function, easy to unit test
with a fake clock. The same math is also exposed as a pure helper
computeTokenExp(session, graceConfig): number so the producer’s
skip-rule (D7) can call it without paying the HMAC sign cost.
Why not the spec’s signature generateLongLivedToken(booking, session)? Passing whole Drizzle rows couples the token service
to the schema and bloats the testing surface. Passing the
two timestamps directly is testable in isolation.
Considered alternatives.
- New
kindfield — rejected above. - Separate
LongLivedBookingTokenService— duplicates HMAC logic, two boot-time secret reads where one suffices.
D5 — PDF service location, shape, and session-time rendering
Section titled “D5 — PDF service location, shape, and session-time rendering”Decision: lib placement. New shared library:
libs/shared/booking-ticket-pdf/src/lib/booking-ticket-pdf.service.tslibs/shared/ (not libs/features/) because the service is a pure
renderer with no DB access and no domain dependency. Mirrors the
existing libs/shared/booking-verify-token/ pattern. Importing it
from both EmailProcessor (in libs/features/notifications) and the
confirmation-dispatch helper (in libs/features/activities) cannot
introduce a cycle because libs/shared/* does not depend on either.
Public API.
interface RenderBookingTicketPdfInput { booking: { id: string; bookingNumber?: string }; session: { startsAt: Date; endsAt: Date | null }; activity: { title: string }; company: { name: string; logoUrl: string | null }; customer: { fullName: string }; longLivedToken: string; // encoded into the QR locale: 'en' | 'uk' | 'ru' | 'de' | 'fr'; entryInstructionsText: string; // pre-resolved i18n string (D9) accentColor?: string; // null => fallback per AC-3 logoBytes?: Buffer | null; // pre-fetched bytes; null => text-only header}
@Injectable()export class BookingTicketPdfService { async render(input: RenderBookingTicketPdfInput): Promise<Buffer>;}Pure function — all data passed in, no DB or HTTP I/O inside
render. This makes the service trivially mockable in the
EmailProcessor spec and keeps the heavy load (Drizzle joins, logo
fetch) in the caller (the worker) where they can run on the same
DB pool the worker already holds.
Single A4 page enforcement (AC-4). pdfkit’s addPage is called
exactly once at the top of render. Content overflow is clipped by
laying out from top to bottom with explicit doc.y positioning and
hard-stopping after the last required block (booking ID + QR). The
QR is rendered at a fixed coordinate near the bottom so it is always
visible regardless of upstream text length.
Logo fetch (AC-2, AC-3). The spec hints at logo fetch latency
risk. Decision: render() accepts pre-fetched logoBytes (or
null), it does NOT fetch the logo itself. Logo bytes are fetched in
the caller (EmailProcessor) with a 2 s timeout via a tiny
fetchLogoBytes(url) helper inside the processor. If the fetch
fails or times out, the caller passes logoBytes: null to render()
and the PDF falls back to text-only header (AC-2). This keeps
render() independent of external I/O and contains the slow-path
inside the worker, not a DB transaction.
Session date/time rendering. AC-1 says “session date and time (formatted in the session’s timezone)”. Investigation confirms:
sessions.startsAtandsessions.endsAtare Drizzletimestamp(nottimestamp({ withTimezone: true })) — naked wall-clock, TZ-less (seeactivities.schema.ts:367-368).locationstable has notimezone/tzcolumn (seeactivities.schema.ts:177-187). No FK target exists to source TZ.- No
sessions.timezonecolumn either.
There is no place in the data model that carries the session’s IANA timezone. So the PDF cannot render a real session-local timestamp today. Concrete behaviour:
- Render dates and times as
DD MMM YYYY, HH:mm(locale-formatted viaIntl.DateTimeFormatwith the booking’s locale, no TZ suffix), using the timestamp value exactly as stored insessions.startsAt/endsAt. - This matches the existing in-app rendering behaviour (the mobile and web apps display sessions as wall-clock too — neither has TZ-aware formatting).
- The PDF must NOT print “UTC” or any other TZ label, because the stored value is not actually UTC — it is whatever wall-clock the business entered.
This is a known data-model limitation, NOT a presentation choice. A
follow-up ticket is required to add locations.timezone (and a
backfill default per company location). Once that lands, regenerated
tokens / re-emailed PDFs will pick up the new format; PDFs already
mailed will be permanently in the wall-clock format. See “Risks”
section.
D6 — Environment variables and config
Section titled “D6 — Environment variables and config”Decision. Add three env vars (two grace minutes + one
feature kill-switch), read at the calling module’s process.env[...]
accessor at boot time (matches the project’s existing pattern — see
BOOKING_VERIFY_SIGNING_SECRET in activities-client.module.ts:40).
No central NestConfigModule exists today, so introducing one is out
of scope.
| Var | Default | Min | Purpose |
|---|---|---|---|
BOOKING_PDF_TOKEN_GRACE_AFTER_END_MIN | 30 | 0 | Grace minutes after session.endsAt for long-lived token exp |
BOOKING_PDF_TOKEN_GRACE_FROM_START_MIN | 240 | 60 | Grace minutes after session.startsAt when endsAt is null |
BOOKING_PDF_TICKET_ENABLED | true | — | Kill-switch — when false, the dispatchConfirmedBookingTicket helper returns immediately without enqueuing. |
Kill-switch rationale. The critic flagged that pdfkit memory
under burst load is a real ops concern. The kill-switch is low-cost
insurance: a single boolean check at the top of the dispatch helper.
Default true (feature is live), can be flipped to false in
production via env without redeploy if we see memory pressure or
deliverability issues. Worker is unaffected — if the queue ever has
in-flight jobs when the switch flips off, the worker still processes
them. The switch only stops new enqueues.
Validation. Boot-time validation lives in the module wiring
(activities-client.module.ts, bookings-scanner.module.ts,
plus any new module that needs the token). The helper:
function readGraceMinutes(name: string, defaultMin: number, minAllowed: number): number { const raw = process.env[name]; if (raw === undefined || raw === '') return defaultMin; const n = Number(raw); if (!Number.isInteger(n) || n < minAllowed) { throw new Error(`${name} must be an integer >= ${minAllowed} (got: ${JSON.stringify(raw)})`); } return n;}
function readBoolean(name: string, defaultValue: boolean): boolean { const raw = process.env[name]; if (raw === undefined || raw === '') return defaultValue; if (raw === 'true' || raw === '1') return true; if (raw === 'false' || raw === '0') return false; throw new Error(`${name} must be 'true'|'false'|'1'|'0' (got: ${JSON.stringify(raw)})`);}The helpers live in libs/shared/booking-verify-token/src/lib/ (the
grace helpers) and the new libs/shared/booking-ticket-pdf/src/lib/
(the kill-switch helper). Fail fast on boot — never silently fall
back to the default when the env var is mis-typed.
Grounding. AC-6 (grace minutes); kill-switch is an ADR-level decision noted as “ops follow-up” in the spec risks.
D7 — Dispatch helper + five confirmation paths
Section titled “D7 — Dispatch helper + five confirmation paths”Spec vs ADR deviation flagged. Spec AC-13 enumerates three PENDING_PAYMENT → CONFIRMED paths (LiqPay, DEFER settle, wallet
/pay). Investigation confirmed Monobank is a fourth active payment provider running the same transition (payments.service.ts:312-317). This ADR enumerates four PENDING_PAYMENT paths plus the direct-CONFIRMED creation site as a separate fifth call point — five total dispatch sites. A spec amendment to add Monobank to AC-13 will be opened by the build pipeline as a small follow-up MR. The implementation will not wait on it; missing Monobank wiring is the high-impact risk called out in the risk register.
Decision. A single helper named dispatchConfirmedBookingTicket(booking, session) is wired into all five call sites. The helper resolves
recipient + locale, evaluates the AC-12 skip rule using a pure
computeTokenExp(session, graceConfig) helper (no HMAC sign cost),
and enqueues a slim BOOKING_TICKET job. It never renders a
PDF. Rendering is the worker’s job (D10).
Five call sites. Each one is wired explicitly — no shared post-confirmation hook exists today, so the single helper is the simplest invariant keeper.
| # | Confirmation path | File | Where status: 'CONFIRMED' is written |
|---|---|---|---|
| 1 | Direct CONFIRMED at create (bookWithWallet, bookWithBonus) | bookings-client.service.ts | 510, 529, 557, 583, 985, 1047 (all bookWith* helpers) |
| 2 | Wallet /pay (PENDING_PAYMENT → CONFIRMED) | bookings-client.service.ts | 492 (payBookingFromWallet) |
| 3 | LiqPay webhook | payments.service.ts | 171 |
| 4 | Monobank webhook (spec gap — see deviation note above) | payments.service.ts | 312–317 |
| 5 | DEFER admin settle | bookings.service.ts | 242 (BookingsService.settle) |
Helper shape. Lives in a NEW service:
libs/features/activities/src/lib/services/booking-confirmation-email.service.ts@Injectable()export class BookingConfirmationEmailService { constructor( private readonly db: DbService, private readonly messaging: MessagingService, private readonly logger: Logger, @Inject(GRACE_CONFIG) private readonly graceConfig: GraceConfig, @Inject(FEATURE_FLAGS) private readonly flags: { pdfTicketEnabled: boolean }, ) {}
// Fire-and-forget at the call site — never throws back to the caller. // Booking confirmation is the source-of-truth event; email is a side effect. async dispatchConfirmedBookingTicket(booking: DbBooking, session: DbSession): Promise<void> { if (!this.flags.pdfTicketEnabled) return;
const exp = computeTokenExp(session, this.graceConfig); if (exp < Math.floor(Date.now() / 1000)) { this.logger.warn( `PDF email skipped: token would be expired immediately. bookingId=${booking.id}`, ); return; // AC-12 skip rule. }
const recipient = await this.resolveRecipient(booking.customerId); if (!recipient.email) { this.logger.warn(`PDF email skipped: no recipient email. bookingId=${booking.id}`); return; } const locale = this.resolveLocale(recipient.userId); // D9 chain.
// Slim queue payload. No PDF bytes, no html body. await this.messaging.sendEmail({ kind: 'BOOKING_TICKET', bookingId: booking.id, sessionId: session.id, to: recipient.email, locale, } as EmailJobPayload); }}Important: this is ADDITIVE, NOT a replacement. The existing
fireBookingNotifications(...) call (which sends the regular
BOOKING_CREATED push + HTML email) continues unchanged. The PDF
email goes out on top of it as a separate transactional message
(D10). The acceptable behaviour is “customer gets the BOOKING_CREATED
push + a separate PDF email with the ticket attached”. This avoids
breaking AC-17.
Cross-module wiring. LiqPay/Monobank webhook handlers live in
libs/features/payments, which doesn’t (and shouldn’t) depend on
bookings-client.service.ts or the activities client lib’s full
module graph. The helper is exported via a new
BookingConfirmationEmailModule that PaymentsModule,
ActivitiesClientModule, and BookingsAdminModule all import.
Retry safety / dedup. MessagingService.sendEmail computes the
jobId as ${recipients}|${subject}|${html} hashed + minute bucket
(messaging.service.ts:25-33). For our slim BOOKING_TICKET
payloads, the producer enqueues with subject and html both
undefined — the hash collapses to ${to}|undefined|undefined +
minute. This is insufficient for our use case. Two distinct
bookings to the same recipient inside the same minute would
collide.
The helper therefore overrides the jobId at the queue level:
// Inside MessagingService.sendEmail we add a path that respects// an explicit jobId if the payload carries one.await this.messaging.sendEmail(payload, { jobId: `booking-ticket-${booking.id}` });Stable, per-booking jobId — any retry-storm or duplicate
confirmation event collapses into one job. Spec vs reality
documented: the current messaging service does NOT take a jobId
override; this MR adds that path (single-line change to
MessagingService.sendEmail — accept MessagingOptions.jobId).
Grounding. AC-11, AC-12, AC-13 (extended with Monobank), AC-14.
D8 — Scanner compatibility: no code change
Section titled “D8 — Scanner compatibility: no code change”Decision. No modification to BookingsVerifyService.verify
(bookings-verify.service.ts:69) or the scanner controller
(bookings-scanner.controller.ts:49). The existing verify path —
mounted at POST /api/scanner/bookings/verify (NOT the spec’s
typo /api/scanner/verify) — checks exp >= now and nothing else
TTL-related. A long-lived token with exp 24–48 h in the future
passes the check exactly as a 30 s token does.
The spec body text uses /api/scanner/verify; a separate spec
hotfix MR will correct the path. This ADR uses the correct path
throughout.
Test coverage. Add a unit test in
libs/shared/booking-verify-token/src/lib/__tests__/booking-verify-token.service.spec.ts
that signs two tokens for the same bookingId — one with
ttlSec: 25, one with ttlSec: 172_800 (48 h) — and verifies
both successfully decode to the same bookingId. Add an
integration test under
libs/features/activities/src/lib/services/__tests__/ that exercises
the full POST /api/scanner/bookings/verify path with both TTLs.
Grounding. AC-8, AC-9, AC-10.
D9 — Locale resolution and i18n entry-instructions
Section titled “D9 — Locale resolution and i18n entry-instructions”Decision. Locale resolution chain (highest priority first):
userProfiles.language(column atusers.schema.ts:47) — if customer has a linked user.- Hardcoded
'en'.
customer.locale and company.defaultLocale are mentioned by the
spec but neither column exists today
(company-customer.schema.ts has no locale; companies.schema.ts
has no defaultLocale). Adding them is out of scope for X1 —
tracked as follow-ups. The current chain stays robust and
documented.
Accepted locales: en, uk, ru, de, fr — matches the 5 files at
apps/api/src/i18n/{en,uk,ru,de,fr}.json and the
nestjs-i18n setup. Any unknown value falls back to 'en'.
Entry-instructions i18n key. Add the key
booking.pdf.entry_instructions to each of the five i18n JSON files
with the translations listed in AC-19.
Resolving the string inside the worker. The EmailProcessor
(BullMQ context — outside HTTP scope) cannot use
I18nContext.current(host). Inject I18nService from nestjs-i18n
directly and call:
const text = await this.i18n.translate('booking.pdf.entry_instructions', { lang: locale });Pass text to BookingTicketPdfService.render({ ..., entryInstructionsText: text }).
i18n CSV output for the build pipeline. Per the user’s
preference, emit a key,en,uk,ru,de,fr CSV row at task end with the
new key.
Grounding. AC-19.
D10 — Worker pipeline, graceful degradation, and transactional bypass
Section titled “D10 — Worker pipeline, graceful degradation, and transactional bypass”Where rendering happens (single source of truth). Inside
EmailProcessor.process. The producer never renders.
override async process(job: Job<EmailJobPayload>): Promise<void> { // Existing PLAIN path — unchanged. if (job.data.kind !== 'BOOKING_TICKET') { await this.email.sendEmail(job.data as SendEmailPayload); return; }
// NEW BOOKING_TICKET path. const { bookingId, sessionId, to, locale } = job.data;
// 1. Load booking + session + activity + company + customer in one query. const data = await this.bookingTicketData.load(bookingId, sessionId); if (!data) { this.logger.warn(`BOOKING_TICKET job: data not found. bookingId=${bookingId}`); return; // Booking deleted between enqueue and pick-up; nothing to send. }
// 2. Build long-lived token + i18n text + logo bytes (best-effort). const { token } = this.tokenService.generateLongLivedToken({ bookingId, sessionStartsAt: data.session.startsAt, sessionEndsAt: data.session.endsAt, graceAfterEndMin: this.grace.afterEnd, graceFromStartMin: this.grace.fromStart, }); const entryInstructionsText = await this.i18n.translate( 'booking.pdf.entry_instructions', { lang: locale }, ); const logoBytes = await fetchLogoBytes(data.company.logoUrl, 2_000).catch(() => null);
// 3. AC-15 — render or degrade. let attachments: SendEmailAttachment[] | undefined; try { const pdf = await this.pdfService.render({ booking: data.booking, session: data.session, activity: data.activity, company: data.company, customer: data.customer, longLivedToken: token, locale, entryInstructionsText, logoBytes, }); attachments = [{ filename: `ticket-${bookingId}.pdf`, contentBase64: pdf.toString('base64'), contentType: 'application/pdf', }]; } catch (err) { this.logger.warn( `PDF render failed for booking ${bookingId}; sending email without attachment`, err, ); attachments = undefined; }
// 4. Build the rich email payload and send. await this.email.sendEmail({ to, subject: await this.i18n.translate('booking.pdf.email.subject', { lang: locale }), html: this.buildHtmlBody(data, locale), attachments, });}AC-15 — PDF rendering failure. The render-or-degrade branch above. The email is sent either way (with or without attachment). The booking is not affected — the status transition already happened in the producer before the worker ever ran.
AC-16 — transactional bypass + justification. The existing
NotificationFeatureService.notify path applies prefs.emailEnabled
unconditionally (notification-feature.service.ts:137) and
emailEnabled defaults to false (notification-feature.service.ts:196).
For the PDF ticket we deliberately bypass
NotificationFeatureService altogether and call
MessagingService.sendEmail directly from
BookingConfirmationEmailService (and the worker calls
EmailChannel.sendEmail via the existing channel path, which also
bypasses NotificationFeatureService).
Justification. The PDF email is the sole external
confirmation when the customer has the default emailEnabled = false
— in that case the existing BOOKING_CREATED notify path’s email
branch is suppressed, and only the push remains. Bypassing
NotificationFeatureService.notify ensures the ticket always reaches
the customer regardless of preference, which is the right semantics
for a transactional artefact: the customer paid for a ticket, they
get the ticket. We do NOT create a /me/notifications row for
the PDF email because the existing BOOKING_CREATED notify path
already creates an in-app entry for the booking — duplicating it
would clutter the in-app list without adding information.
Recipient resolution. From the booking’s customerId, join
companyCustomers.email (companies.schema.ts:122) first, then
users.email via companyCustomers.userId → users.id as fallback.
If neither yields an email — log a warning, skip the dispatch (no
email to send, but the booking is still confirmed).
from field. Currently
process.env['RESEND_FROM'] || 'noreply@tktspace.com'
(email.channel.ts:17). Customer sees the platform domain, not the
company’s. Known limitation; per-company from is out of scope
for X1 and tracked as a follow-up.
Considered alternatives (architecture-level)
Section titled “Considered alternatives (architecture-level)”- Render PDF on demand via a new
GET /me/bookings/:id/ticket.pdfendpoint, and put a CTA link in the existing HTML email instead of attaching a file. Trade-off: smaller emails, no Buffer in payload, no BullMQ JSON bloat. Rejected by spec — non-goal at the top of the spec explicitly carves out “No regenerate PDF on demand endpoint” as out of scope for X1. The attachment-by-default flow is required. - Reuse
NotificationFeatureService.notify({ email, emailContext })with a newtransactional: trueflag. Cleaner in principle but requires extendingNotifyParamsand the email-fallback branch ofnotifyto (a) skip theprefs.emailEnabledcheck, (b) thread anattachmentsarray through. Both changes touch a hot path used by every notification type. The bypass-and-dispatch-directly approach in D10 isolates the change. Revisit when there’s a second transactional email type. - Store the long-lived token in
bookingsso it survives across restarts and can be re-emailed. Adds a column, breaks the stateless-HMAC property of the verify foundation, and creates an audit-log obligation (you can never delete the column without invalidating already-issued tickets). Tokens are deterministic given{bid, iat, exp, secret}— we can always re-derive at X2 / “resend ticket” time.
API design (per surface)
Section titled “API design (per surface)”None. X1 adds no HTTP endpoints, no DTOs, no operation IDs, no contract changes. The mobile/scanner/web/business surfaces all stay byte-identical.
Surface impact
Section titled “Surface impact”| Contract | Touched? | Why |
|---|---|---|
contracts/client.openapi.yaml | No | No new client endpoint; the PDF arrives by email, out-of-band. |
contracts/business.openapi.yaml | No | POST /api/business/bookings/:id/settle body and response unchanged; only adds an internal post-confirmation side-effect. |
contracts/scanner.openapi.yaml | No | POST /api/scanner/bookings/verify request and response unchanged. AC-8 / AC-9 confirm the path accepts long-lived tokens without code change. |
contracts/super-admin.openapi.yaml | No | Not affected. |
No sync:contracts run is required for this MR (would produce an
empty diff). The sync:contracts:check CI gate will pass with the
current snapshots.
Data model
Section titled “Data model”None — no schema changes.
- Sessions already carry
startsAtandendsAt(activities.schema.ts:367-368). - Tokens are stateless HMAC, not persisted.
- The transactional email bypasses the notifications history table.
- New env vars are runtime config, not schema.
No drizzle migration ships with this ticket.
drafts/migration-long-lived-signed-token-pdf-ticket-via-email.sql
is intentionally a no-op stub — see “Migration outline” below.
Backend module placement
Section titled “Backend module placement”| Concern | Lib / file |
|---|---|
| Long-lived token method | libs/shared/booking-verify-token/src/lib/booking-verify-token.service.ts (add generateLongLivedToken + computeTokenExp pure helper) |
| Grace-minutes env parser | libs/shared/booking-verify-token/src/lib/booking-verify-token.constants.ts (export readGraceMinutes helper) |
| PDF renderer | libs/shared/booking-ticket-pdf/src/lib/booking-ticket-pdf.service.ts (NEW SHARED LIB — pure renderer, no DB. Moved from libs/features/ to libs/shared/ to match booking-verify-token and break the notifications → activities cycle.) |
| Kill-switch env parser | libs/shared/booking-ticket-pdf/src/lib/booking-ticket-pdf.constants.ts (export readBoolean helper + BOOKING_PDF_TICKET_ENABLED constant name) |
| Confirmation-email helper | libs/features/activities/src/lib/services/booking-confirmation-email.service.ts (NEW) — exposes dispatchConfirmedBookingTicket(booking, session). |
| Module additions | BookingConfirmationEmailModule (new) re-exports BookingConfirmationEmailService; imported by ActivitiesClientModule, BookingsAdminModule, PaymentsModule. BookingTicketPdfService is provided by BookingTicketPdfModule (new) and imported by EmailProcessorModule. |
| Email payload extension | libs/shared/messaging/src/lib/interfaces/notification-channel.interface.ts (extend SendEmailPayload with attachments; add discriminated EmailJobPayload union with BOOKING_TICKET) + libs/shared/messaging/src/lib/channels/email/email.channel.ts (forward attachments to Resend) + libs/shared/messaging/src/lib/messaging.service.ts (accept MessagingOptions.jobId override for stable per-booking dedup) |
| PDF generation point | libs/features/notifications/src/lib/processors/email.processor.ts — branch on job.data.kind. PLAIN path unchanged; BOOKING_TICKET path does the load + render + attach + send pipeline. |
| Booking-ticket data loader | libs/features/activities/src/lib/services/booking-ticket-data.service.ts (NEW) — one-query loader returning { booking, session, activity, company, customer }. Imported by the worker. |
| i18n strings | apps/api/src/i18n/{en,uk,ru,de,fr}.json — add booking.pdf.entry_instructions and booking.pdf.email.subject |
| Env config | apps/api/.env.example — document BOOKING_PDF_TOKEN_GRACE_AFTER_END_MIN, BOOKING_PDF_TOKEN_GRACE_FROM_START_MIN, BOOKING_PDF_TICKET_ENABLED |
| New deps | tktspace-backend/package.json — pdfkit, qrcode, @types/pdfkit (dev), @types/qrcode (dev) |
No cycle. libs/shared/booking-ticket-pdf and
libs/shared/booking-verify-token are both leaf libs/shared/* packages
with no dependency on libs/features/*. libs/features/notifications
imports libs/shared/booking-ticket-pdf and
libs/features/activities/booking-ticket-data (one-way).
libs/features/activities imports
libs/shared/booking-ticket-pdf only indirectly via the worker — its
own BookingConfirmationEmailService doesn’t need the PDF service at
all (producer-only).
Frontend implications
Section titled “Frontend implications”None. Web, business, landing — no changes. Mobile — no changes.
The mobile app may eventually want a “resend ticket email” button in the booking-details screen; that is explicitly out of scope per the spec and tracked for a follow-up after X2.
Mobile implications
Section titled “Mobile implications”None. No mobile app (gym_app, tickets_app) or shared package
(api, auth, checkout, core, i18n, notifications,
profile, ui) is affected.
packages/api does NOT need regeneration — no contract changes.
Test plan
Section titled “Test plan”One bullet per acceptance criterion, mapped to a concrete test
location and shape. All tests live in tktspace-backend.
- AC-1 / AC-2 / AC-3 / AC-4 — PDF rendering. Unit test on
BookingTicketPdfService.render. Drive with three input scenarios: (a) full inputs incl. logo bytes — assert returned Buffer is non-empty, starts with%PDF, and is under 1 MB; (b)logoBytes: null— assert render still succeeds (AC-2 fallback); (c) oversized booking number / customer name — assert single page produced (pdfkitbufferedPageRange().count === 1, AC-4). - AC-5 — long-lived token
expmath. Unit test onBookingVerifyTokenService.generateLongLivedTokenwith a fake clock. Two synthetic sessions: (a)endsAt = now + 1h→exp === endsAt + 30*60; (b)endsAt = null, startsAt = now + 1h→exp === startsAt + 240*60. - AC-6 — env validation. Unit test on
readGraceMinutesandreadBooleancovering: missing → default; valid → returned; invalid (non-integer, below min, garbage) → throws with the variable name in the message. - AC-7 —
generateToken(30 s) untouched. Existing tests must pass unchanged. CI gate, no new test. - AC-8 / AC-9 / AC-10 — scanner accepts both TTLs. Integration
test on
POST /api/scanner/bookings/verifydriving two signed tokens for the samebookingId:ttlSec: 25(in-app) andttlSec: 172_800(48 h email). Both must yieldCHECKED_IN. - AC-11 — PDF on direct CONFIRMED creation. Integration test
exercising
bookWithWallet(which creates a booking withstatus: 'CONFIRMED'). Mocked BullMQ queue. Assert exactly oneBOOKING_TICKETjob enqueued with the booking’s id. - AC-12 — skip rule. Unit test on
BookingConfirmationEmailService.dispatchConfirmedBookingTicketwith two synthetic sessions and a fake clock: (a)endsAt + 30m < now— assert helper returns without enqueuing and logs awarnline containing the booking id; (b)endsAt + 30m > now— assert one job is enqueued. - AC-13 — all 4 PENDING_PAYMENT paths + direct CONFIRMED.
Integration test per path:
- LiqPay webhook →
payments.service.ts:171, - Monobank webhook →
payments.service.ts:312-317, - DEFER admin settle →
bookings.service.ts:242, - Wallet
/pay→bookings-client.service.ts:492, - Direct CONFIRMED at create →
bookings-client.service.ts:113,153(or anybookWith*helper). Each test drives the booking from its starting status to CONFIRMED and asserts the BullMQ queue received exactly oneBOOKING_TICKETjob with the booking’s id.
- LiqPay webhook →
- AC-14 — no PDF on HTTP hot path. Integration test:
call
POST /api/client/bookings(direct CONFIRMED path) with the PDF service mocked to record invocations. Assert that the PDF service was NOT called during the HTTP request (it should only be called from the worker, which doesn’t run in this test). - AC-15 — graceful degradation. Unit test on
EmailProcessorwithBookingTicketPdfService.rendermocked to throw. AssertEmailChannel.sendEmailis called once withattachments: undefinedand awarnline is logged with the booking id. - AC-16 — transactional bypass. Integration test: customer
whose
userProfiles.emailEnabled = false. Drive the booking to CONFIRMED, run the worker pipeline against a mocked Resend client. Assert the mocked Resend.emails.sendis called exactly once — theemailEnabled = falseflag does not suppress it. - AC-17 —
BOOKING_CREATEDnotification preserved. Integration test: assert the existingfireBookingNotifications(...)path still runs and produces its existing push + email per the existing template, in addition to the new PDF email. - AC-18 — no contract change. CI gate via
npm run sync:contracts:checkfrom the backend repo. Must pass with no diff against the snapshots in_workflow/contracts/. - AC-19 — i18n keys present in all five locales. Snapshot test
on the PDF text content with each of
en, uk, ru, de, frlocales selected — assert the entry-instructions string matches the translation table in AC-19 for each locale.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| pdfkit + qrcode memory under concurrent bursts | Medium | Worker pod restart under sustained load | BullMQ concurrency cap is already 10 (email.processor.ts:6). Monitor worker RSS after deploy; tighten to 5 if needed. Kill-switch BOOKING_PDF_TICKET_ENABLED=false available as immediate mitigation without redeploy. |
| Silent PDF generation failure (AC-15 degrades to warn-log only) | Medium | Customers receive plain emails without PDF and no operator alert | Out of scope for X1 to add a metric. Ops follow-up: add a Prometheus counter pdf_render_failed_total OR a structured-log alert (level=warn, code=pdf_render_failed) so the SRE rotation notices a spike. Flagged in the risk register for the build pipeline to file as a separate ticket. |
from field on PDF email — customer sees noreply@tktspace.com, not the company’s domain | Medium | Brand experience is generic | Known limitation; per-company from is out of scope for X1. Documented in D10. Follow-up ticket needed when business asks. |
| Logo fetch timeout blocking the worker | Medium | One job slot stuck for 2 s per slow logo | 2 s timeout in the fetch helper, fall back to text-only header (AC-2). |
| BullMQ attachment payload size (base64 inflates ~33%) | Low | Job payload may approach Redis value-size warnings for very heavy logos | Mitigated by D3: the queue carries only the slim BOOKING_TICKET payload (no PDF bytes). The 200–270 KB base64 only appears in the in-worker SendEmailPayload between BookingTicketPdfService.render and EmailChannel.sendEmail — never on a Redis-persisted job. |
| Resend attachment size limit | Very low | Email dropped by Resend with 40 MB cap | Pdfkit single-page typically <500 KB. Add an assertion in the worker (if (pdf.length > 5 * 1024 * 1024) skip with warning) — well under Resend’s limit but catches a runaway loop. |
| Two payment providers (LiqPay + Monobank), one missed | High if Monobank not wired | Monobank-paying customers get no PDF | This ADR explicitly identifies Monobank at payments.service.ts:312-317 as a fourth confirmation path (D7); backend-dev MUST wire dispatchConfirmedBookingTicket from both webhook branches. Spec amendment opened by build pipeline as a small follow-up MR. |
userProfiles.language is text (no constraint) — could be 'pt', '', or null | Medium | i18n returns key string instead of translated text | Fallback to 'en' for any value not in {en, uk, ru, de, fr} (D9). |
session.endsAt declared notNull but spec defensively codes for null | Low | Dead branch | Keep the null branch in generateLongLivedToken for forward-compat; covered by a unit test that exercises both branches with a fake input shape. |
| Race: customer cancels booking between confirmation and worker pick-up | Low | Email + PDF sent for a now-CANCELLED booking | Acceptable; the QR will still validate but the scanner will 400 with not_verifiable_status (bookings-verify.service.ts:128). Log only. |
BullMQ jobId dedup eats a legit second send | Low (X1 has no resend endpoint) | Resend silently dropped | D7 helper sets jobId = booking-ticket-${booking.id}. Within X1 scope no manual resend exists. Document for X2 — when “resend ticket” lands, the producer must derive a non-stable jobId (e.g. include a nonce) for that path. |
| Token live for ~hours of session: replay window after CHECKED_IN | Low | Already in the system (30 s tokens have same issue); spec risk register mentions it | Out of scope here; the verify endpoint already 409s on CHECKED_IN replay (bookings-verify.service.ts:122). |
| No timezone data in schema → PDF renders wall-clock without TZ label | Medium | Customer in a multi-region venue may misinterpret the time | Documented in D5. Render as DD MMM YYYY, HH:mm with no TZ suffix — matches existing in-app behaviour. Follow-up ticket required for locations.timezone. Once introduced, regenerated PDFs use the new format but already-mailed PDFs are stale — acceptable, as venue-side mitigation is to print/state local time on signage. |
Cycle between notifications and activities libs | Eliminated by D5 placement | — | BookingTicketPdfService now in libs/shared/booking-ticket-pdf. Both notifications and activities import shared — no cycle. |
Rollout plan
Section titled “Rollout plan”Phased.
- Phase A — merge the schema-free, no-side-effect changes:
new deps (
pdfkit,qrcode), new shared libsbooking-ticket-pdfand the additions tobooking-verify-token, payload extension onSendEmailPayload(+EmailJobPayloadunion),MessagingService.sendEmailjobIdoverride path,generateLongLivedTokenmethod onBookingVerifyTokenService, the three env vars in.env.examplewith defaults (30 / 240 /true). Unit tests for token, PDF rendering, env parsers. WorkerBOOKING_TICKETbranch added but no producer call sites yet. No call sites wired. Risk = zero. - Phase B — wire
dispatchConfirmedBookingTicketfrom the direct-CONFIRMED path only (bookings-client.service.ts). Smoke test in the dev environment: create a booking, confirm a PDF email lands in Resend’s dashboard, QR scans in the scanner app and transitions to CHECKED_IN. - Phase C — wire the four PENDING_PAYMENT → CONFIRMED paths
(LiqPay, Monobank, DEFER settle, wallet
/pay). End-to-end regression on all four in dev. - Phase D — production deploy. Watch for: pdfkit heap growth,
Resend delivery rate, BullMQ queue backlog. Monitor for 48 h
before X2 starts. If issues — flip
BOOKING_PDF_TICKET_ENABLED=falsewithout redeploy.
Feature flag. Yes — BOOKING_PDF_TICKET_ENABLED (default
true). See D6 for rationale. The flag is checked in
dispatchConfirmedBookingTicket only; the worker still drains any
in-flight jobs when the flag flips off.
Backfill? Not applicable.