Skip to content

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)”

Two prior tickets shipped the customer-ticket-verify foundation:

  • #14a (booking-verify-token, merged 2026-06-18) introduced BookingVerifyTokenService (libs/shared/booking-verify-token/src/lib/booking-verify-token.service.ts) with HMAC-SHA256 sign/verify. The public API is sign({ bookingId, ttlSec? }) / verify(token) — note: the spec calls this generateToken but the actual method name in code is sign. 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 to BookingsVerifyService at libs/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:

  1. A long-lived variant of the verify token, with exp derived from session timing (session.endsAt + grace or session.startsAt + grace).
  2. A BookingTicketPdfService that renders an A4 PDF (company logo, activity, session date/time, customer name, QR encoding the long-lived token, booking ID, entry-instructions string).
  3. Wiring of the PDF email on every PENDING_PAYMENT → CONFIRMED path (direct CONFIRMED booking, LiqPay webhook, Monobank webhook, DEFER settle, wallet /pay), via the existing BullMQ notifications-email queue.

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”
ConcernFile / line
Token service (sign / verify)libs/shared/booking-verify-token/src/lib/booking-verify-token.service.ts:129
Token signing secret envBOOKING_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 endpointlibs/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 interfacelibs/shared/messaging/src/lib/interfaces/notification-channel.interface.ts:1SendEmailPayload has no attachments field today
Email queue + processorlibs/features/notifications/src/lib/processors/email.processor.ts:6 (concurrency 10)
Email producerlibs/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 templatelibs/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:77emailEnabled defaults to false (line 196)
BOOKING_CREATED triggerlibs/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 confirmationlibs/features/activities/src/lib/services/bookings-client.service.ts:457 (payBookingFromWallet) — sets status: 'CONFIRMED' at line 492
LiqPay webhook → CONFIRMEDlibs/features/payments/src/lib/services/payments.service.ts:171
Monobank webhook → CONFIRMEDlibs/features/payments/src/lib/services/payments.service.ts:312-317
DEFER / admin settlelibs/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 tablelibs/shared/data-access-db/src/lib/schema/activities.schema.ts:354startsAt + endsAt both NOT NULL; columns are timestamp (naked wall-clock, no withTimezone)
Locations tablelibs/shared/data-access-db/src/lib/schema/activities.schema.ts:177-187NO timezone / tz column
Companies tablelibs/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 preferencelibs/shared/data-access-db/src/lib/schema/users.schema.ts:47userProfiles.language
i18n loaderlibs/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 defaultlibs/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:

  1. Monobank is a second payment provider running the same PENDING_PAYMENT → CONFIRMED transition (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.
  2. No accentColor, no companyDefaultLocale, no session.timezone, no locations.timezone columns 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.
  3. sessions.startsAt / endsAt are timestamp (NOT timestamptz) — they store naked wall-clock values without TZ info. There is also no locations.timezone. So the PDF spec phrase “session timezone” has no data backing today. See “Session date/time rendering” decision in D5.

Ship X1 as a backend-only change layered on top of the existing BookingVerifyTokenService, MessagingService, and EmailProcessor. Add three new things:

  1. A BookingTicketPdfService (new file under libs/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 a Buffer — pure rendering, no DB access. Lives in libs/shared/ to match the existing libs/shared/booking-verify-token/ pattern (pure helper, no DB) and to avoid the notifications → activities import cycle.
  2. 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.
  3. A PDF-rendering branch in EmailProcessor.process that activates when job.data.kind === 'BOOKING_TICKET'. The worker fetches booking
    • session + activity + company + customer, calls BookingTicketPdfService.render, attaches the resulting Buffer (via the extended SendEmailPayload.attachments field), and calls EmailChannel.sendEmail.

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.

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").

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.

libs/shared/messaging/src/lib/interfaces/notification-channel.interface.ts
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 than bid and exp. Adding a kind field would change the wire format without giving the scanner any more information than exp already conveys.
  • Shared HMAC key means a single rotation point. A kind field 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 kind field — 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.ts

libs/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.startsAt and sessions.endsAt are Drizzle timestamp (not timestamp({ withTimezone: true })) — naked wall-clock, TZ-less (see activities.schema.ts:367-368).
  • locations table has no timezone / tz column (see activities.schema.ts:177-187). No FK target exists to source TZ.
  • No sessions.timezone column 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 via Intl.DateTimeFormat with the booking’s locale, no TZ suffix), using the timestamp value exactly as stored in sessions.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.

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.

VarDefaultMinPurpose
BOOKING_PDF_TOKEN_GRACE_AFTER_END_MIN300Grace minutes after session.endsAt for long-lived token exp
BOOKING_PDF_TOKEN_GRACE_FROM_START_MIN24060Grace minutes after session.startsAt when endsAt is null
BOOKING_PDF_TICKET_ENABLEDtrueKill-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 pathFileWhere status: 'CONFIRMED' is written
1Direct CONFIRMED at create (bookWithWallet, bookWithBonus)bookings-client.service.ts510, 529, 557, 583, 985, 1047 (all bookWith* helpers)
2Wallet /pay (PENDING_PAYMENT → CONFIRMED)bookings-client.service.ts492 (payBookingFromWallet)
3LiqPay webhookpayments.service.ts171
4Monobank webhook (spec gap — see deviation note above)payments.service.ts312–317
5DEFER admin settlebookings.service.ts242 (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):

  1. userProfiles.language (column at users.schema.ts:47) — if customer has a linked user.
  2. 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.

libs/features/notifications/src/lib/processors/email.processor.ts
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.pdf endpoint, 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 new transactional: true flag. Cleaner in principle but requires extending NotifyParams and the email-fallback branch of notify to (a) skip the prefs.emailEnabled check, (b) thread an attachments array 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 bookings so 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.

None. X1 adds no HTTP endpoints, no DTOs, no operation IDs, no contract changes. The mobile/scanner/web/business surfaces all stay byte-identical.

ContractTouched?Why
contracts/client.openapi.yamlNoNo new client endpoint; the PDF arrives by email, out-of-band.
contracts/business.openapi.yamlNoPOST /api/business/bookings/:id/settle body and response unchanged; only adds an internal post-confirmation side-effect.
contracts/scanner.openapi.yamlNoPOST /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.yamlNoNot 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.

None — no schema changes.

  • Sessions already carry startsAt and endsAt (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.

ConcernLib / file
Long-lived token methodlibs/shared/booking-verify-token/src/lib/booking-verify-token.service.ts (add generateLongLivedToken + computeTokenExp pure helper)
Grace-minutes env parserlibs/shared/booking-verify-token/src/lib/booking-verify-token.constants.ts (export readGraceMinutes helper)
PDF rendererlibs/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 parserlibs/shared/booking-ticket-pdf/src/lib/booking-ticket-pdf.constants.ts (export readBoolean helper + BOOKING_PDF_TICKET_ENABLED constant name)
Confirmation-email helperlibs/features/activities/src/lib/services/booking-confirmation-email.service.ts (NEW) — exposes dispatchConfirmedBookingTicket(booking, session).
Module additionsBookingConfirmationEmailModule (new) re-exports BookingConfirmationEmailService; imported by ActivitiesClientModule, BookingsAdminModule, PaymentsModule. BookingTicketPdfService is provided by BookingTicketPdfModule (new) and imported by EmailProcessorModule.
Email payload extensionlibs/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 pointlibs/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 loaderlibs/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 stringsapps/api/src/i18n/{en,uk,ru,de,fr}.json — add booking.pdf.entry_instructions and booking.pdf.email.subject
Env configapps/api/.env.example — document BOOKING_PDF_TOKEN_GRACE_AFTER_END_MIN, BOOKING_PDF_TOKEN_GRACE_FROM_START_MIN, BOOKING_PDF_TICKET_ENABLED
New depstktspace-backend/package.jsonpdfkit, 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).

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.

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.

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 (pdfkit bufferedPageRange().count === 1, AC-4).
  • AC-5 — long-lived token exp math. Unit test on BookingVerifyTokenService.generateLongLivedToken with a fake clock. Two synthetic sessions: (a) endsAt = now + 1hexp === endsAt + 30*60; (b) endsAt = null, startsAt = now + 1hexp === startsAt + 240*60.
  • AC-6 — env validation. Unit test on readGraceMinutes and readBoolean covering: 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/verify driving two signed tokens for the same bookingId: ttlSec: 25 (in-app) and ttlSec: 172_800 (48 h email). Both must yield CHECKED_IN.
  • AC-11 — PDF on direct CONFIRMED creation. Integration test exercising bookWithWallet (which creates a booking with status: 'CONFIRMED'). Mocked BullMQ queue. Assert exactly one BOOKING_TICKET job enqueued with the booking’s id.
  • AC-12 — skip rule. Unit test on BookingConfirmationEmailService.dispatchConfirmedBookingTicket with two synthetic sessions and a fake clock: (a) endsAt + 30m < now — assert helper returns without enqueuing and logs a warn line 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 /paybookings-client.service.ts:492,
    • Direct CONFIRMED at create → bookings-client.service.ts:113,153 (or any bookWith* helper). Each test drives the booking from its starting status to CONFIRMED and asserts the BullMQ queue received exactly one BOOKING_TICKET job with the booking’s id.
  • 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 EmailProcessor with BookingTicketPdfService.render mocked to throw. Assert EmailChannel.sendEmail is called once with attachments: undefined and a warn line 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.send is called exactly once — the emailEnabled = false flag does not suppress it.
  • AC-17 — BOOKING_CREATED notification preserved. Integration test: assert the existing fireBookingNotifications(...) 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:check from 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, fr locales selected — assert the entry-instructions string matches the translation table in AC-19 for each locale.
RiskLikelihoodImpactMitigation
pdfkit + qrcode memory under concurrent burstsMediumWorker pod restart under sustained loadBullMQ 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)MediumCustomers receive plain emails without PDF and no operator alertOut 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 domainMediumBrand experience is genericKnown 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 workerMediumOne job slot stuck for 2 s per slow logo2 s timeout in the fetch helper, fall back to text-only header (AC-2).
BullMQ attachment payload size (base64 inflates ~33%)LowJob payload may approach Redis value-size warnings for very heavy logosMitigated 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 limitVery lowEmail dropped by Resend with 40 MB capPdfkit 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 missedHigh if Monobank not wiredMonobank-paying customers get no PDFThis 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 nullMediumi18n returns key string instead of translated textFallback to 'en' for any value not in {en, uk, ru, de, fr} (D9).
session.endsAt declared notNull but spec defensively codes for nullLowDead branchKeep 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-upLowEmail + PDF sent for a now-CANCELLED bookingAcceptable; 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 sendLow (X1 has no resend endpoint)Resend silently droppedD7 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_INLowAlready in the system (30 s tokens have same issue); spec risk register mentions itOut 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 labelMediumCustomer in a multi-region venue may misinterpret the timeDocumented 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 libsEliminated by D5 placementBookingTicketPdfService now in libs/shared/booking-ticket-pdf. Both notifications and activities import shared — no cycle.

Phased.

  1. Phase A — merge the schema-free, no-side-effect changes: new deps (pdfkit, qrcode), new shared libs booking-ticket-pdf and the additions to booking-verify-token, payload extension on SendEmailPayload (+ EmailJobPayload union), MessagingService.sendEmail jobId override path, generateLongLivedToken method on BookingVerifyTokenService, the three env vars in .env.example with defaults (30 / 240 / true). Unit tests for token, PDF rendering, env parsers. Worker BOOKING_TICKET branch added but no producer call sites yet. No call sites wired. Risk = zero.
  2. Phase B — wire dispatchConfirmedBookingTicket from 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.
  3. Phase C — wire the four PENDING_PAYMENT → CONFIRMED paths (LiqPay, Monobank, DEFER settle, wallet /pay). End-to-end regression on all four in dev.
  4. 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=false without 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.