Introduce an alternative "proactive" control flow for PDF report generation. Instead of generating reports on-demand when the user clicks "Download", the system will automatically trigger a background PDF generation process as soon as Tesla vehicle data is successfully fetched. This minimizes perceived latency by having the PDF ready in a GCS bucket before the user even requests it.
pdf-api (Node.js)puppeteer-core.bnc-cpt-api repository at bnc-cpt-api/src/nodejs/pdf-api/. This mirrors the existing Node-inside-Python-repo pattern (bnc-cpt-wui/src/nodejs/the-bot/). It is not a separate top-level module.con-${ORG}-${APP}-pdf-api (compose service pdf-api in bnc-cpt-utl/src/docker/docker-compose-app.yaml), attached to the shared all_lcl_docker network.028-gcp-vpc-connector). Deployed via an extension to step 030-gcp-cloud-run.pdf-api does not receive vehicle data on the request. It receives only the minimum context needed to navigate the WUI print route. The data is re-fetched live by the WUI from cpt-api using the print token (see §5.5). This keeps pdf-api a single-purpose renderer with no business-logic surface.${var.org}-${var.app}-${var.env}-reports, already provisioned by bnc-cpt-inf/src/terraform/016-gcp-reports-bucket/. No new bucket is created. (The earlier proposed name ${ORG}-${APP}-${ENV}-reports-pdf is rejected — accept the existing …-reports.)pdf/report-{vin}-{timestamp}.pdf to keep them isolated from the existing receipts/legacy-WeasyPrint objects in the same bucket.pdf-api service account is granted roles/storage.objectCreator and roles/storage.objectViewer scoped to this bucket — narrower than the roles/storage.objectAdmin already granted to the cpt-api SA. The IAM bindings are added to step 016-gcp-reports-bucket.Redis is the single source of truth for both the Tesla session/report data and the PDF lifecycle metadata. No new datastore is introduced.
app/services/order_session_service.py + Tesla flow — reused as-is):session_id and per vin. The print route fetches this data through the existing GET /api/v1/tesla/report/{session_id} endpoint, which already reads from Redis.pdf:report:{vin} → {timestamp} — proactive PDF availability marker. TTL 30m.pdf:report:{vin}:failed → 1 — set when all 3 pdf-api retries failed; signals the download path to fall back to WeasyPrint without re-checking GCS. TTL 30m.pdf:hook:{random_id} → JSON {session_id, vin, gcs_object, timestamp} — the download hook record. {random_id} is a server-generated unguessable handle, secrets.token_urlsafe(16) (≥128 bits of entropy), minted by cpt-api when pdf-api reports successful upload. The WUI embeds it in the Download button's DOM id as but_{random_id}_print_pdf and sends it back on click. TTL 30m.pdf:hooks:by-session:{session_id} → SET of {random_id} — reverse index for cleanup. Allows the scheduled task to enumerate every pdf:hook:* key tied to a session in O(1) without a Redis SCAN. Pushed to atomically alongside pdf:hook:{random_id} creation. TTL 30m.tesla:oauth:success:{session_id} → {epoch_timestamp} — anchor for the cleanup window (see §4). TTL 35m.bnc-cpt-api/src/python/cpt-api/app/core/redis.py (already hardened for GCP Memorystore + AUTH).cpt-api) successfully retrieves and processes vehicle metrics. The session — including the per-VIN report data — is already in Redis.cpt-api mints a short-lived JWT (TTL 60s) with claims {session_id, vin, purpose: "print", exp} using the existing JWT_SECRET_KEY (see §5.5). The session_id is the UI-control hook: it is the same identifier the WUI dashboard already operates on, and it is the Redis lookup key for the session/report data. The print token is therefore a signed, time-boxed pointer from the UI control state into Redis.cpt-api fires a non-blocking internal request to pdf-api via asyncio.create_task(...) so the user response is not delayed.POST http://con-${ORG}-${APP}-pdf-api:3000/generate{ vin, user_id, locale, print_token } — no data payload; the WUI re-fetches from Redis via the print token. (session_id is not duplicated in the payload because it is already a claim inside print_token.)X-Internal-Secret. The secret is provisioned via step 029-create-gcp-secrets and injected into both containers as an env var.pdf-api)pdf-api accepts the request and immediately returns 202 Accepted. Rendering happens asynchronously on the worker pool.js
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 2 });
deviceScaleFactor: 2 produces 2× pixel density so chart strokes and rasterized images are crisp in the PDF. Both values are configurable via env (PDF_VIEWPORT_WIDTH, PDF_VIEWPORT_HEIGHT, PDF_DEVICE_SCALE_FACTOR) — defaults baked for the WUI's intended desktop breakpoint.js
await page.goto(`http://con-${ORG}-${APP}-wui/report/print/${vin}?token=${print_token}`,
{ waitUntil: 'networkidle0', timeout: 30_000 });
await page.waitForFunction('window.isRenderComplete === true', { timeout: 15_000 });waitUntil: 'networkidle0' waits until there are no in-flight network requests for ≥500 ms — ensures all data fetches, image loads, and font swaps have settled.waitForFunction(...) waits for an explicit page-side signal: the WUI print view sets window.isRenderComplete = true only after ReportContent* is mounted, all data is bound, ECharts have finished their finished event for every chart instance, and any photo <img> tags have fired load/error. This guards against blank charts caused by ECharts animations not having completed before the PDF snapshot.ReportContent* components — no nav, no menus, no tabs, no buttons (see §5.2).waitForFunction timeout, non-2xx data fetch), pdf-api retries up to 3 times with exponential backoff.pdf-api POSTs a failure notice to cpt-api (POST /api/v1/internal/pdf-render-failed with X-Internal-Secret). cpt-api records this in Redis (pdf:report:{vin}:failed = 1) so the download path knows to fall back to WeasyPrint without re-checking GCS.await page.pdf({ format: 'A4', printBackground: true, preferCSSPageSize: true }) — printBackground: true so the WUI's branded background colors/images survive in the PDF; preferCSSPageSize: true so any @page rules in the WUI's print stylesheet take precedence.gs://${ORG}-${APP}-${ENV}-reports/pdf/report-{vin}-{timestamp}.pdf.pdf-api POSTs success back to cpt-api (POST /api/v1/internal/pdf-render-complete with X-Internal-Secret and {session_id, vin, timestamp, gcs_object}); cpt-api:random_id = secrets.token_urlsafe(16).pdf:hook:{random_id} = JSON {session_id, vin, gcs_object, timestamp} with TTL 30m.SADD pdf:hooks:by-session:{session_id} {random_id} and EXPIRE to 30m (reverse index for cleanup).pdf:report:{vin} = timestamp (presence marker) with TTL 30m.random_id in the response so the next polling call from the WUI can deliver it to the dashboard (see §3.3 step 2).pdfHookId: Ref<string | null> (initially null) and pdfFallbackMode: Ref<boolean> (initially false). The "Download PDF" button is rendered disabled while pdfHookId === null && !pdfFallbackMode.GET /api/v1/tesla/pdf-status/{session_id}/{vin} every ~750ms (max ~10s budget). The endpoint inspects Redis:pdf:hook:* was registered for the (session_id, vin) pair: returns {ready: true, random_id: "<opaque>"}.pdf:report:{vin}:failed is set: returns {ready: false, fallback: true}.{ready: false} — WUI keeps polling until the budget elapses.random_id, it sets store.pdfHookId = random_id. Vue's reactivity system handles the rest: a :id="…" binding on the button (:id="pdfHookId ? \but_${pdfHookId}_print_pdf` : 'but_pending_print_pdf'") re-renders synchronously, and awatch(() => store.pdfHookId, …)in the dashboard component enables the click handler atomically with the id update. **No manualdocument.getElementById(...)mutation.** This eliminates the race where a click between "hook lands" and "DOM rewrite completes" would otherwise hit the disabled placeholder. If the polling budget elapses with no hook (store.pdfFallbackMode = true`), the watch flips the button into fallback mode (see step 5b).mountedAt timestamp + computed disabledUntilGrace flag in the store, so users can't click before either the proactive PDF lands or the fallback path is selected.@click handler reads store.pdfHookId (the reactive source of truth, not the DOM) and calls:GET /api/v1/tesla/pdf/{store.pdfHookId} (proactive path).cpt-api parses {random_id}, looks up pdf:hook:{random_id} in Redis:410 Gone with {fallback: true}; the WUI then transparently calls the legacy on-the-fly endpoint (POST /api/v1/tesla/vehicle-report/pdf) which uses WeasyPrint.store.pdfFallbackMode === true, no random_id was ever issued): the button calls the legacy on-the-fly endpoint directly. WeasyPrint generates synchronously.random_id (rendered via Vue's reactive :id binding, never via direct DOM manipulation).tesla.py::handle_tesla_oauth_callback completes successfully). The countdown is anchored to the OAuth callback success — not to PDF generation, not to user login to the app, not to session creation.cpt-api (running every minute) inspects Redis-tracked OAuth-success timestamps and deletes any matching GCS objects + Redis keys whose anchor exceeds 30 min.cpt-api SETs tesla:oauth:success:{session_id} = {epoch_timestamp} with TTL 35m.gs://…/pdf/report-{vin}-*.pdf objects for all VINs in that session (resolved via the existing per-session VIN list maintained by order_session_service).SMEMBERS pdf:hooks:by-session:{session_id} → for each {random_id}: DEL pdf:hook:{random_id}.DEL pdf:hooks:by-session:{session_id}.DEL pdf:report:{vin} and DEL pdf:report:{vin}:failed for every VIN in the session.DEL tesla:oauth:success:{session_id}.cpt-api is required for the spec's semantics. A coarser GCS lifecycle rule (e.g. 24h hard ceiling) may be retained as belt-and-suspenders.pdf-api)puppeteer-core (Chrome provided via the Docker base image, not bundled).generic-pool or workerpool, fixed size = os.cpus().length (overridable via env).X-Internal-Secret middleware on all routes.GET /health for compose / Cloud Run probes.page.goto):await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 2 }) — desktop layout + 2× pixel density. Width/height/scale are env-tunable (PDF_VIEWPORT_WIDTH, PDF_VIEWPORT_HEIGHT, PDF_DEVICE_SCALE_FACTOR).await page.emulateMediaType('print') — ensures @media print rules apply during rendering (otherwise screen styles dominate).window.isRenderComplete page-side flag (see §5.2 + §3.2 step 3).page.goto(url, { waitUntil: 'networkidle0', timeout: 30_000 }) — wait until network is quiet (≥500 ms with no in-flight requests).page.waitForFunction('window.isRenderComplete === true', { timeout: 15_000 }) — wait for the WUI's explicit ready signal. Both timeouts are env-tunable.page.pdf({ format: 'A4', printBackground: true, preferCSSPageSize: true }). printBackground preserves the WUI's branded backgrounds; preferCSSPageSize honors any @page declaration in the WUI's print stylesheet (page size, margins, orientation).ReportContentBasic.vue and ReportContentPro.vue from ReportDashboardBasic.vue / ReportDashboardPro.vue. Content components must depend only on props or store state — never on route state, interactive UI context, or URL.@media print rules to ensure they target the new Content components (existing no-print class hygiene already in place in App.vue/ReportDashboard*.vue is preserved).isShadowReportRoute pattern):vue-router. Routing is done via raw path checks in App.vue (e.g. isShadowReportRoute = window.location.pathname.startsWith('/shadow/report')).isPrintReportRoute = window.location.pathname.startsWith('/report/print/').App.vue mounts a single new component ReportPrintView.vue that:token from the query string.GET /api/v1/tesla/report/{session_id}?token={token} to fetch the report payload (uses the existing endpoint with broadened auth — see §5.5).ReportContentBasic or ReportContentPro based on the user's plan.vue-router (too large a refactor for one route), separate print.html SPA (defeats the live-HTML rationale).window.isRenderComplete): ReportPrintView.vue must explicitly tell the renderer when the page is fully painted. Without this flag, Puppeteer can capture a frame mid-ECharts-animation and produce blank/partial charts.window.isRenderComplete = false.onMounted (or after data load resolves), the view registers a chartFinishedPromise per ECharts instance — each ECharts instance fires a 'finished' event once its animation completes; the print view collects these into Promise.all([...chartFinishedPromises, ...imageLoadPromises]).<img> rendered inside ReportContent* resolves via its onload/onerror handler (or img.complete && img.naturalHeight > 0 poll) → contributes to imageLoadPromises.await nextTick() to flush any final reactive updates → set window.isRenderComplete = true.window.isRenderComplete = true even if individual signals stall, so Puppeteer's 15s waitForFunction always observes a transition.stores/app.ts: pdfHookId: string | null = null, pdfFallbackMode: boolean = false, pdfPollingActive: boolean = false.ReportDashboard*.vue::onMounted and writes only to the store. The button is bound to these reactive fields via::id="pdfHookId ? 'but_' + pdfHookId + '_print_pdf' : 'but_pending_print_pdf'":disabled="(!pdfHookId && !pdfFallbackMode) || withinGracePeriod"@click="onDownloadClick" — handler reads store.pdfHookId directly; never touches document.getElementById.watch(() => store.pdfHookId, …) is reserved for side effects (e.g. analytics events, telemetry); button enable/disable is purely declarative via the :disabled binding above.mountedAt = Date.now() + computed withinGracePeriod = (Date.now() - mountedAt) < 3000. Combined into the :disabled expression above.index.html for unknown paths is already in place (the /shadow/report precedent confirms this); no nginx change needed.pdf-api SA into bnc-cpt-inf/src/terraform/016-gcp-reports-bucket/05.reports-bucket.tf:roles/storage.objectCreatorroles/storage.objectViewer030-gcp-cloud-run with a second service definition for pdf-api, ingress = INTERNAL.${ORG}-${APP}-${ENV}-pdf-api@…iam.gserviceaccount.com provisioned in step 003-gcp-iam-users (or alongside Cloud Run in 030).bnc-cpt-internal-secret to step 029-create-gcp-secrets. Value populated manually via gcloud secrets versions add per the project convention.028-gcp-vpc-connector is reused — pdf-api joins the same connector so it can reach Memorystore Redis and be reached internally from cpt-api.pdf-api Cloud Run ingress is INTERNAL. Only callers inside the VPC (i.e. cpt-api Cloud Run) can reach it.X-Internal-Secret header validated on every pdf-api route and on the cpt-api callback endpoints (/api/v1/internal/pdf-render-complete, /api/v1/internal/pdf-render-failed).but_{random_id}_print_pdf) carries an opaque server-generated handle, not the session_id. Rationale: the button id is exposed in the DOM, browser extensions, error-tracker DOM captures (Sentry etc.), screenshots, and screen recordings. A session_id would grant wider authority (it identifies the entire Tesla session — all VINs, all reports — and is consumed by other session-keyed endpoints). The random_id grants exactly one capability: download exactly one PDF, for ≤30m. Generation: secrets.token_urlsafe(16) (≥128 bits of entropy). The session_id is kept entirely server-side / in WUI memory state and never becomes a user-visible identifier on a click target.session_id that the WUI dashboard is already operating on, which is the Redis lookup key for the cached session/report data. (Rejected alternative: piggybacking on the session JWT — too long-lived, too broad if leaked, anti-pattern to put long-lived tokens in URLs/logs.)python-jose HS256 + settings.JWT_SECRET_KEY already used by app/services/auth.py::create_access_token / verify_token.{session_id, vin, purpose: "print", exp = now + 60s}.Helpers (added to app/services/auth.py):
```python
def create_print_token(session_id: str, vin: str, ttl_seconds: int = 60) -> str:
return create_access_token(
{"session_id": session_id, "vin": vin, "purpose": "print"},
expires_delta=timedelta(seconds=ttl_seconds),
)
def verify_print_token(token: str, expected_session_id: str, expected_vin: str) -> bool:
data = verify_token(token)
return bool(
data
and getattr(data, "purpose", None) == "print"
and getattr(data, "session_id", None) == expected_session_id
and getattr(data, "vin", None) == expected_vin
)
``
- **Model**:TokenData(app/models/auth.py) gains optional fieldspurpose: Optional[str],session_id: Optional[str],vin: Optional[str].
- **Endpoint reuse — no new endpoint**: The print route uses the **existing**GET /api/v1/tesla/report/{session_id}endpoint, which already reads the session/report data from Redis. Its auth is broadened to accept *either* the regular session JWT *or* a validpurpose=printtoken (with matchingsession_id). When a print token is presented, the response is filtered to the single VIN named in the token's claim — preventing the print token from being used to enumerate other VINs in the session.
- The print token in the URL query string (?token=…) is decoded; if it validates and containspurpose=print, the path-paramsession_idmust match the token'ssession_idclaim.
- **Why this is safe in URLs**: the token is dead in ≤60s. Even if it appears in nginx access logs or a CDN cache, it cannot be replayed beyond its TTL, cannot be used for any other endpoint (purpose=printis enforced), and is bound to a single(session_id, vin)` pair.
| # | Question | Decision |
|---|---|---|
| 1 | Module placement of pdf-api |
Co-located at bnc-cpt-api/src/nodejs/pdf-api/ |
| 2 | New bucket vs reuse existing | Reuse existing ${ORG}-${APP}-${ENV}-reports (step 016-gcp-reports-bucket) |
| 3 | WUI routing for print view | Extend isShadowReportRoute pattern (no vue-router, no separate SPA) |
| 4 | Cleanup trigger | 30 min after Tesla OAuth token fetch success, scheduled task in cpt-api, anchored on tesla:oauth:success:{session_id} |
| 5 | Print-route auth | Dedicated short-lived print JWT (60s TTL, purpose=print, VIN-bound) — not session JWT |
| 6 | Bucket name (-reports vs -reports-pdf) |
Accept existing -reports, prefix objects under pdf/ |
| 7 | Data passing to pdf-api |
No data in payload — pdf-api receives only {vin, user_id, locale, print_token}; WUI re-fetches via print token |
| 8 | Print token claims | Includes session_id — the print token is the UI-control "hook" tying the WUI dashboard state to the Redis-cached session/report data |
| 9 | Reuse existing report endpoint vs new one | Reuse GET /api/v1/tesla/report/{session_id} — broaden auth to accept print token; no new endpoint |
| 10 | Download button identifier | Opaque server-generated random_id (secrets.token_urlsafe(16)) embedded as but_{random_id}_print_pdf — never expose session_id or vin in DOM ids |
| 11 | Download flow | WUI polls GET /api/v1/tesla/pdf-status/{session_id}/{vin} for hook readiness; click → GET /api/v1/tesla/pdf/{random_id} → signed GCS URL or 410 Gone triggering WeasyPrint fallback |
| 12 | Redis cleanup completeness | Cleanup task explicitly deletes pdf:hook:{random_id} (via reverse-index pdf:hooks:by-session:{session_id}), pdf:report:{vin}, pdf:report:{vin}:failed, and the anchor — TTLs are a safety net only |
| 13 | Puppeteer viewport for visual parity | 1920×1080, deviceScaleFactor: 2, emulateMediaType('print') — env-tunable; ensures ECharts/photos are crisp and desktop layout is used (no mobile collapse) |
| 14 | Puppeteer readiness signal | Combined: waitUntil: 'networkidle0' plus waitForFunction('window.isRenderComplete === true') — WUI sets the flag after all ECharts 'finished' events + image loads + nextTick |
| 15 | WUI button binding mechanism | Reactive Pinia state (store.pdfHookId) bound via :id / :disabled — no document.getElementById mutation; closes the click-during-rewrite race |
Operational Specification v1.6.0 — Crisp-render Puppeteer config (1920×1080 @2×, networkidle0 + window.isRenderComplete) · Race-free reactive button binding · Explicit Redis cleanup with reverse-index · Hook-id download mechanism · Reuse /report/{session_id} for print fetch · Co-located pdf-api · Reuse existing reports bucket · Path-based print route · Short-lived print token · OAuth-anchored cleanup