Skip to main content

EP-012D — Report Builder & Scheduled Delivery RTM

EPIC: EP-012D Report Builder & Scheduled Delivery Parent EPIC: EP-012 Analytics, Reporting & Night Audit Reconciled: 2026-05-25 (Wave-1 parallel reconciliation, 21 EPICs in flight) Template: follows EP-002-rtm.md per ./PLAYBOOK.md. Source of truth for "% passing": hmp-analytics-service/CLAUDE.md + per-service CLAUDE.md Gotchas tables (per platform CLAUDE.md Audit Ground Truth §). Live flow-test pass-rate doc (2026-05-16-13-flow-demo-readiness.md) does NOT enumerate the saved-views / scheduled-reports flow.

Status legend​

StatusMeaning
BuiltCode exists + tests exist + tests pass
Built-UntestedCode exists, no automated test coverage (or test exists but is a stub)
PartialCode exists for some acceptance criteria, gaps for others
StubCode is a placeholder / TODO / returns mock data
Not StartedNo code

Summary​

MetricCount
Total stories (per EPIC body)8 (US-012-16, US-012-20, US-012D-01, US-012D-02, US-012D-03, US-012D-04, US-012D-05, US-012D-06)
Catalog claims "Built"0 — catalog has no EP-012D-specific rows; US-012-008 "Custom Report Builder" = Partial; US-012-015 "Report Scheduling (Auto-Email)" = Not Started
Verified-Built0
Built-Untested0
Partial5 (US-012-16, US-012D-01, US-012D-02, US-012D-03, US-012D-06)
Stub1 (US-012D-04 — XLSX exists in the v2 gold-standard framework BUT EPIC body claims it as a separate sub-story; the sub-story's hotel-letterhead/summary-tab AC is not met)
Not Started2 (US-012-20 if interpreted per EPIC body's Sprint-4 acceptance criteria; US-012D-05 — no RuleRunHistory-equivalent UI for scheduled reports)
Drift findings11 (D-001..D-011; 4 P0 / 5 P1 / 1 P2 — D-008 + D-001 + D-004 + D-005 are P0; D-002 + D-003 + D-006 + D-007 + D-009 + D-010 are P1; D-011 is P2)
[SME-PROPOSED] refinements26 (multi-bullet drift entries counted individually)

US-012-16 — Custom Report Builder​

Catalog status: Partial (P2, 21 points; rolls up to catalog row US-012-008 Custom Report Builder per the EP-012 catalog — the EP-012D EPIC body relabels this as US-012-16. The two IDs are the same story — see D-001.) Verified status: Partial — gold-standard ReportingModule v2 with 63 ReportDefinition-based reports + catalog + run + audit-trail shipped (Plan 0/1/2/2.1/2.3/3, 2026-04-30); legacy report-builder.service with Map-backed in-memory custom reports STILL EXISTS in code per US-012-008 review (2026-04, pre-v2). There is no end-user "drag-and-drop dimension/measure" builder UI today — the dashboard's /reports/v2 is a CATALOG of pre-registered reports plus a "Save View" pattern (saved filter+period combo), not an ad-hoc query builder.

Code locations:

  • hmp-analytics-service/src/reporting/reporting.module.ts — v2 framework module (registers 63 ReportDefinitions at boot via ReportDefinitionsRegistrar)
  • hmp-analytics-service/src/reporting/controller/reports-v2.controller.ts:1-60 — GET /reports/v2/catalog, POST /reports/v2/:id/run, GET /reports/v2/:id/runs (the canonical surface)
  • hmp-analytics-service/src/reporting/saved-views/saved-views.controller.ts:38-178 — Plan 3.4 saved-view CRUD: POST/GET/GET:id/PATCH/DELETE /reports/v2/saved-views + POST /reports/v2/saved-views/:id/schedule
  • hmp-analytics-service/src/reporting/saved-views/saved-views.service.ts — owner-or-shared visibility, ADMIN+ required to set shared=true
  • hmp-analytics-service/src/reporting/saved-views/dto/saved-view.dto.ts — Create/Update/Schedule DTOs
  • hmp-analytics-service/src/reporting/execution/report-execution.service.ts — single pipeline (RBAC → audit-start → resolver → redact → drill-annotate → format → audit-complete)
  • hmp-analytics-service/src/reporting/registry/report.registry.ts [CITED-BUT-NOT-FOUND] — in-memory ReportRegistry (per CLAUDE.md spec; exact filename not opened)
  • hmp-analytics-service/src/reporting/reports/*.definition.ts — 63 pre-registered ReportDefinitions (revenue-daily, occupancy, arrival, departure, einvoice, gst, …) — these are the "reports" users actually run
  • hmp-analytics-service/src/report-builder/report-builder.service.ts [CITED-BUT-NOT-FOUND] — legacy in-memory custom-report-builder per US-012-008 review (2026-04). Not surfaced in v2; deletion candidate (see D-006)
  • hmp-web-dashboard/src/app/(dashboard)/reports/v2/page.tsx — Plan 10 catalog card grid
  • hmp-web-dashboard/src/app/(dashboard)/reports/v2/[reportId]/page.tsx — generic report runner page (PeriodSelector + CompareFlagsToggle + PropertySetSelector + ReportTable + DrillDownDrawer + ExportButton + SaveViewModal star)
  • hmp-web-dashboard/src/app/(dashboard)/reports/v2/saved/page.tsx — Plan 3.4 saved views index page
  • hmp-web-dashboard/src/app/(dashboard)/reports/v2/saved/_components/EditSavedViewDialog.tsx + ScheduleSavedViewDialog.tsx — W3.D.1 (2026-05-07) edit + schedule dialogs
  • hmp-web-dashboard/src/components/modules/reports/v2/SaveViewModal.tsx — Plan 3.4 shadcn Dialog (fields: name, pinnedToHome, shared visible to ADMIN+ only) per hmp-web-dashboard/CLAUDE.md
  • hmp-web-dashboard/src/components/modules/reports/v2/PinnedReportsWidget.tsx — home-page widget for pinned views
  • hmp-web-dashboard/src/lib/api/saved-views.ts — typed client for /reports/v2/saved-views

Unit + integration tests:

  • hmp-analytics-service/src/reporting/saved-views/saved-views.controller.spec.ts — exists (per directory listing)
  • hmp-analytics-service/src/reporting/saved-views/saved-views.service.spec.ts — exists (per directory listing)
  • hmp-analytics-service/src/reporting/controller/reports-v2.controller.spec.ts — exists (per directory listing)
  • hmp-analytics-service/src/reporting/reports/*.definition.spec.ts — 63 per-report-definition spec files (each report has a paired .definition.spec.ts)
  • hmp-analytics-service/src/report-builder/report-builder.service.spec.ts — 4 tests (per US-012-008 review): create, preview (revenue only), export to PDF, schedule delivery — legacy

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/saved-views-schedules.flow.spec.ts :: S1 POST create private + S2 GET list + S3 PATCH update name/pin + S4 GET pinned filter + S5 POST create shared (admin) + S6 POST :id/schedule + S7 GET /report-schedules + S8 DELETE — 8 scenarios; pass rate TBD (not enumerated in 2026-05-16-13-flow-demo-readiness.md — see D-002)

Real-backend smokes:

  • none specific to v2 / saved-views (live-backend smoke test for /reports/v2 not present per directory tree)

E2E (Playwright):

  • hmp-web-dashboard/e2e/reports-v2.spec.ts — 9 specs per hmp-web-dashboard/CLAUDE.md (catalog mount, report render, period change, drill-down open/close, export dropdown, PDF trigger, legacy link)

Use cases referencing this story:

  • none found under docs/requirements/use-cases/ (search for "report-builder" / "custom-report" / "saved-view" — see D-003)

SME notes:

  • The EPIC body and the reality have diverged in opposite directions. The EPIC body still describes a Sprint-5 "drag-and-drop dimension/measure" Custom Report Builder; what shipped is the gold-standard v2 framework — a catalog of 63 pre-registered ReportDefinitions with a saved-filters layer on top. The two design patterns serve different user intents: v2 favours "many small canned reports + comparator/drill-down" over "one mega ad-hoc builder". Operators planning the next sprint will mis-allocate engineering if they read the EPIC body's "21 points" budget for AC-1 (drag-drop) — that surface is not on the roadmap and v2 makes it largely unnecessary.
  • The legacy report-builder.service (US-012-008 review, 2026-04) is still in the codebase per the review file; v2 (Plan 0/1) deleted the old ReportsModule but did NOT delete the older ReportBuilderModule. That module's in-memory Map<string, SavedReport> (lost on restart) is dead weight and a tenant-isolation footgun. Drop it (see D-006) or wire it into Prisma-persisted CustomReport rows — pick one, don't leave both.
  • The POST /reports/v2/saved-views/:id/schedule endpoint doesn't validate cron expression syntax — invalid cron passed to BullMQ at registration silently fails the first job execution but accepts the row. Add a validator at DTO boundary (see D-007).
  • The dashboard SaveViewModal does not provide a "Set as template / clone-from" UX — every saved view starts from scratch. Real-world workflow: GM clones a colleague's saved view, tweaks the property set, saves under new name. Today that's a 4-tab dance (open colleague's view → re-enter filters → Save As). Surface as Day-2 UX gap.

Drift findings:

D-001 — [P0] [Story-numbering-drift + catalog-vs-EPIC-body] The EP-012 catalog (01-epic-catalog.md) has only two rows that correspond to EP-012D content: US-012-008 Custom Report Builder (Partial, P2, 5 points) and US-012-015 Report Scheduling (Auto-Email) (Not Started, P2, 5 points). The EP-012D EPIC body relabels these as US-012-16 (21 points!) and US-012-20 (5 points) AND adds six new EP-012D-prefixed stories that have NO catalog row at all (US-012D-01..06). The point estimate also disagrees (5 catalog vs 21 EPIC for the same story). A new reviewer reading either doc alone will not realise the other exists. P0 pilot-blocking drift.

  • [SME-PROPOSED] Decide the canonical numbering: either add 6 rows US-012-021..US-012-026 to the catalog for the EP-012D-prefixed stories AND adopt US-012-021 Custom Report Builder / US-012-022 Scheduled Report Delivery to replace the mismatched 008/015 catalog rows; OR rewrite the EPIC body to use the catalog's numbering (US-012-008 / US-012-015) plus new US-012-021..US-012-024 for the remaining four EP-012D items. The catalog is the canonical numbering source per pilot D-007 (BF-001).
  • [SME-PROPOSED] Update both docs in the same PR and add an alias line to each story explaining the rename, e.g. > Previously titled US-012-16 in EPIC body draft prior to 2026-05-25 reconciliation.

D-002 — [P1] [Flow-test-coverage-gap] saved-views-schedules.flow.spec.ts exists (8 scenarios end-to-end) but is NOT enumerated in the canonical demo-readiness pass-rate doc (2026-05-16-13-flow-demo-readiness.md). Operators planning a release cannot tell whether saved-views + scheduler work today on QA/staging.

  • [SME-PROPOSED] Add saved-views-schedules.flow.spec.ts to the next XX-flow-demo-readiness.md snapshot with explicit pass-count per scenario. The spec is shipped — it just needs the run telemetry surfaced.

D-003 — [P1] [Orphaned-feature + missing-UC] Saved Views + their scheduling sub-flow have no _data/use-cases/uc-*.yaml (search yielded zero). Plan 3.4 was a sizeable user-facing feature; the absence of a UC sidecar makes it invisible to the Use-Case Index + RTM dashboard rollup. Operators querying "what are our scheduled-report-delivery use cases" find nothing.

  • [SME-PROPOSED] Author uc-saved-view-scheduled-delivery.yaml covering: given a saved view named "Q1 Channel Mix", when a user PATCHes it with cron='0 8 * * 1' + channels=[EMAIL] + format=PDF, then a BullMQ recurring job is registered AND every fire delivers the PDF via EmailChannel to all recipients in the channel config.

US-012-20 — Scheduled Report Delivery​

Catalog status: Not Started (per catalog row US-012-015 Report Scheduling (Auto-Email), P2, 5 points) Verified status: Partial — gold-standard backend shipped (Plan 7, 2026-05-01: ReportScheduleModel + ReportSchedulerService BullMQ-recurring + 4 DeliveryChannels incl. EmailChannel/WhatsAppChannel/S3Channel/SftpChannel + DistributionService aggregator + DistributionListsController CRUD) BUT the EPIC body's AC-1 "P&L on 1st of month → email PDF to CFO" pipeline is incomplete because (a) the dashboard has no Cron-builder UI (only the per-saved-view "Schedule" dialog), (b) no retry-with-15-min-backoff is implemented (per AC-2; today: BullMQ default retries), (c) no delivered/failed status surfaced to operator beyond per-row failureCount + lastFailureMsg.

Code locations:

  • hmp-analytics-service/src/reporting/scheduler/report-scheduler.service.ts:36-78 — ReportSchedulerService (BullMQ Worker + Queue; loads all active schedules at boot; dedupe key = scheduleId; SKIP_REPORT_SCHEDULER=true opt-out)
  • hmp-analytics-service/src/reporting/scheduler/scheduler.module.ts — module wires service + BullMQ deps
  • hmp-analytics-service/src/reporting/distribution/distribution.service.ts — aggregator dispatching to all 4 channels in parallel; partial failures captured
  • hmp-analytics-service/src/reporting/distribution/email.channel.ts + .spec.ts — EmailChannel: posts to notification-service /notifications/email/send via NotificationHttpClient, attaches presigned S3 link when available
  • hmp-analytics-service/src/reporting/distribution/whatsapp.channel.ts + .spec.ts — WhatsappChannel: whitelist restricted to manager-flash-report, revenue-daily-report, arrival-report, departure-report; throws UnsupportedChannelError for others
  • hmp-analytics-service/src/reporting/distribution/s3.channel.ts + .spec.ts — S3Channel: reuses Plan 6 S3ExportStorage; pass-through if report.url already set; no-op when S3 not configured
  • hmp-analytics-service/src/reporting/distribution/sftp.channel.ts + .spec.ts — SftpChannel: ssh2-sftp-client, reads TenantConfig(category=SFTP, key=primary), 3 retries + exponential backoff
  • hmp-analytics-service/src/reporting/distribution/notification-http.client.ts — thin HTTP wrapper for notification-service (POST /notifications/email/send + /notifications/whatsapp/send) — see D-008
  • hmp-analytics-service/src/reporting/distribution-lists/distribution-lists.controller.ts:31-... — POST/GET/GET:id/PATCH/DELETE /distribution-lists (Roles: SUPER_ADMIN, ADMIN, MANAGER)
  • hmp-analytics-service/src/reporting/distribution-lists/distribution-lists.service.ts — tenant-scoped CRUD
  • hmp-analytics-service/prisma/schema.prisma [CITED-BUT-NOT-FOUND] — ReportSchedule model: reportId, cron (POSIX), timezone (IANA, default Asia/Kolkata), channels JSON (DeliveryChannelConfig[]), filters JSON, active, failureCount, lastFailureMsg (per analytics-service CLAUDE.md, table not opened directly)
  • hmp-analytics-service/prisma/schema.prisma [CITED-BUT-NOT-FOUND] — DistributionList model: tenant-scoped named groups, members JSON [{type:'email'|'whatsapp', value:string}]
  • hmp-web-dashboard/src/app/(dashboard)/reports/v2/saved/_components/ScheduleSavedViewDialog.tsx — only schedule-creation entry point in the dashboard (per-saved-view, not a stand-alone "Schedule any catalog report" surface)

Unit + integration tests:

  • hmp-analytics-service/src/reporting/scheduler/report-scheduler.service.spec.ts:1-40+ — mocks BullMQ Queue + Worker; constructor-injection tests; covers boot-time findMany({active:true}) + registerSchedule
  • hmp-analytics-service/src/reporting/distribution/distribution.service.spec.ts — aggregator behaviour, partial-failure handling
  • hmp-analytics-service/src/reporting/distribution/distribution-e2e.spec.ts — end-to-end distribution path
  • hmp-analytics-service/src/reporting/distribution/email.channel.spec.ts, whatsapp.channel.spec.ts, s3.channel.spec.ts, sftp.channel.spec.ts — per-channel coverage
  • hmp-analytics-service/src/reporting/distribution-lists/distribution-lists.controller.spec.ts + distribution-lists.service.spec.ts

Flow tests:

  • saved-views-schedules.flow.spec.ts :: S6 POST /reports/v2/saved-views/:id/schedule + S7 GET /report-schedules?active=true — covers happy path of schedule creation + list (pass rate TBD per D-002)

Real-backend smokes:

  • none

E2E (Playwright):

  • none specific to scheduled delivery (covered transitively via reports-v2.spec.ts if at all)

Use cases referencing this story:

  • none (see D-003)

SME notes:

  • BullMQ default retry is attempts: 1 (no retry). The EPIC body's AC-2 requires "retry once after 15 minutes, then send failure alert to admin". The current code path increments failureCount and lastFailureMsg but does NOT enqueue a retry job. Operators will silently miss a P&L email on transient Redis blip — AC-2 is materially unmet. (See D-004.)
  • The dashboard does not surface schedule status as a dedicated page. Operators who need to answer "did my Monday morning report fire?" must SSH to the analytics-service pod and SELECT * FROM "ReportSchedule" WHERE id=.... The Plan 7 catalog lacks a Run History UI. (See D-005 / US-012D-05.)
  • Distribution-list members JSON shape {type:'email'|'whatsapp', value:string} only supports two destination types. EPIC body suggests cron→email but real-world hotels need: per-recipient channel preference (CFO: email, GM: WhatsApp, owner: SMS), CC/BCC for emails, salutation per recipient. Today's shape doesn't carry any of these. (See D-009.)
  • India scheduling reality: P&L runs at 08:00 IST on the 1st of every month — but the FY runs April→March. The Asia/Kolkata default is correct; what's missing is a fiscal-year-aware "On 1st of FY month-N" cron — e.g. "first day of Q1" = April 1, "first day of Q3" = October 1. Today the operator hand-encodes '0 8 1 4 *' etc.; one digit wrong and the report fires on the wrong month. SME-PROPOSED below.
  • WhatsappChannel whitelist: only 4 reports may be sent via WhatsApp. Reason: 24-hour message window + business-template approval (Plan E3 / Inbox CLAUDE.md). Reasonable, but the operator is not told why when they try to schedule a 5th report via WhatsApp — they just see UnsupportedChannelError. UX gap.

Drift findings:

D-004 — [P0] [AC-vs-code + reliability] EPIC body AC-2 "retry once after 15 minutes, then send failure alert to admin" is materially unmet. ReportSchedulerService increments failureCount + lastFailureMsg on error but does NOT enqueue a delayed-15-min retry; BullMQ defaults to attempts: 1. Scheduled P&L delivery will silently miss on transient Redis/notification-service blips.

  • [SME-PROPOSED] In ReportSchedulerService.registerSchedule, set BullMQ defaultJobOptions: { attempts: 2, backoff: { type: 'fixed', delay: 15 * 60 * 1000 } }. Wire a failed event handler that, on second failure, emits report.scheduled.failed Kafka topic carrying { scheduleId, tenantId, reportId, failureMsg } for ops alerting (consumed by notification-service to send the admin email per AC-2 step 4).
  • [SME-PROPOSED] Add AC: "Scheduled report failure SHALL retry exactly once after 15 minutes. If second attempt also fails, an email-of-record alert SHALL fire to the tenant ADMIN within 5 minutes (via report.scheduled.failed Kafka topic → notification-service)."

D-005 — [P0] [Story-vs-code + missing-UI] Catalog says US-012-015 Report Scheduling = "Not Started"; reality is the backend is shipped (Plan 7, 2026-05-01) but the dashboard surface is a per-saved-view dialog only — there is no stand-alone "Scheduled Reports" page where operators can list/edit/disable/delete schedules. Without that page, the EPIC body's AC-1 cron-builder + recipients UX is silently incomplete.

  • [SME-PROPOSED] Demote catalog to "Partial" (backend shipped, frontend partial) and author the missing dashboard page at /reports/scheduled with: list of all active+inactive ReportSchedules for the tenant, filter by reportId/channel, edit (cron + recipients) inline, disable toggle, delete with confirm. Mount alongside /reports/v2/saved in the sidebar Reports group.
  • [SME-PROPOSED] Add AC: "An operator with role MANAGER+ SHALL be able to navigate to /reports/scheduled, see every ReportSchedule for the tenant (active + inactive), edit cron + recipients + format inline, toggle active, and delete with confirmation. URL state preserves filter selections (URL-linked filter chips per AP-T13-002 pattern)."

D-006 — [P1] [Orphaned-code + tenant-isolation-risk] Legacy report-builder.service with in-memory Map<string, SavedReport> (US-012-008 review, 2026-04) is still in the codebase despite v2 shipping a different abstraction. The Map has no tenant scoping — any user query through the legacy endpoint reads any tenant's reports.

  • [SME-PROPOSED] Delete src/report-builder/ (services, controller, module, DTO, spec) in a follow-up PR. Add a redirect: any caller hitting the legacy /api/analytics/reports (or whatever path it owned) gets 410 Gone with a Link: </api/reports/v2/catalog> header pointing at the new surface.

D-007 — [P1] [Input-validation + reliability] ScheduleSavedViewDto.cron is accepted without syntax validation. An invalid cron expression (e.g. '0 25 * * *' — hour 25) silently fails at BullMQ registration time but the row is persisted.

  • [SME-PROPOSED] Add class-validator + cron-parser validation on the DTO: @IsString() @Validate(IsValidCron). Reject 400 at controller boundary with message cron expression "{cron}" is invalid: {parser error}.

US-012D-01 — Custom Report Query Engine (Backend)​

Catalog status: Not Started (no catalog row — see D-001) Verified status: Partial — v2 framework shipped (63 ReportDefinitions, each typed + auditable + RBAC-checked) but the EPIC body's intent (arbitrary dimension/measure SQL query engine) is not the same surface. The v2 reports' def.query callbacks ARE parameterized via Prisma — there is no SQL injection surface — but they are pre-registered, NOT user-defined.

Code locations:

  • hmp-analytics-service/src/reporting/execution/report-execution.service.ts — single pipeline (RBAC → audit-start → resolver → redact → drill-annotate → format → audit-complete)
  • hmp-analytics-service/src/reporting/resolvers/ (per directory tree; specific files not enumerated) — MVResolver (invokes def.query) + LiveProxyResolver (calls source services via SERVICE_CLIENT_MAP_TOKEN)
  • hmp-analytics-service/src/reporting/reports/*.definition.ts × 63 — each is a typed ReportDefinition factory with schema (dimensions + measures), query (Prisma call), rbac (roles + sensitiveColumns), comparative (LY/Budget/Forecast), drillDown
  • hmp-analytics-service/src/reporting/controller/reports-v2.controller.ts:18-41 — validatePeriod guard at API boundary (added 2026-05-16) rejects malformed/missing filters.period with structured 400
  • hmp-analytics-service/src/reporting/rbac/ (per directory tree) — ReportRBACService.assertCanRun + filterColumns + redactRows; SUPER_ADMIN bypasses

Unit + integration tests:

  • hmp-analytics-service/src/reporting/controller/reports-v2.controller.spec.ts — exists
  • 63 × *.definition.spec.ts — one per ReportDefinition (revenue-daily, occupancy, …)
  • Per-resolver specs in src/reporting/resolvers/ (not enumerated)
  • hmp-analytics-service/src/reporting/execution/report-execution.service.spec.ts [CITED-BUT-NOT-FOUND] — likely exists; not enumerated

Flow tests:

  • none specific to query engine; covered transitively via the per-report flow assertions

Use cases referencing this story:

  • none

SME notes:

  • The 30-second timeout + 100,000-row reject from the EPIC body's AC are not enforced today. ReportExecutionService does not wrap def.query in Promise.race against a timeout; reports that hit a 10-second analytics-DB query just block the request thread. (See D-008.)
  • Per the v2 design (gold-standard reporting design doc, 2026-04-30), the intent is to swap ad-hoc SQL for typed registered definitions. That is a strictly safer + faster surface than the EPIC body's "build a query engine that parses dimension/measure DTO into SQL" approach. The Custom Report Builder AC for "select arbitrary fields" is architecturally obsolete under v2.
  • Tenant isolation: every def.query MUST include WHERE "tenantId" = $1 — there's no central enforcer that fails the build for a definition that forgets to. (See D-010.)

Drift findings:

D-008 — [P0] [Reliability + EPIC-AC-unmet] EPIC body AC-1 "report executes within 10s for <50,000 rows" and AC-1 stop-condition "returns 429 with 'Query too large' if estimated rows > 100,000" are not enforced. ReportExecutionService does not wrap def.query in a timeout; there is no estimated-row-count gate.

  • [SME-PROPOSED] In ReportExecutionService.run(), wrap the resolver call in Promise.race([resolver(), timeoutPromise(30_000)]); on timeout, throw ReportTimeoutError → controller returns 504 Gateway Timeout with {code:'REPORT_TIMEOUT', timeoutMs:30000}.
  • [SME-PROPOSED] Add an EXPLAIN ANALYZE-driven row-estimate gate (or a hard LIMIT 100000 on every def.query returning > N rows) before result formatting; return 429 with {code:'REPORT_TOO_LARGE', estimatedRows, max:100000}.

D-010 — [P1] [Cross-cutting-tenant-isolation + missing-guard] No central enforcer guarantees every ReportDefinition.query includes WHERE "tenantId" = $1. A definition author who omits the filter introduces a cross-tenant data leak.

  • [SME-PROPOSED] Add eslint-plugin-custom/require-tenant-filter (or a compile-time AST check via ReportDefinitionsRegistrar.register) that fails the boot if any registered definition's query string does not include a tenant filter. Alternatively, refactor the resolver layer so def.query receives a pre-bound prisma.<table>.where({tenantId, ...}) rather than a free-form callback.
  • [SME-PROPOSED] Author a cross-tenant denial spec template (per AP-T01-007 / EP-002 pilot pattern) covering all 63 definitions: a SUPER_ADMIN's tenant-A token MUST NOT return tenant-B's data even when explicit tenantId filters in the request body try to widen scope.

US-012D-02 — Report Catalog with Role-Based Access​

Catalog status: Not Started (no catalog row) Verified status: Partial — v2 catalog shipped (GET /reports/v2/catalog) with per-definition rbac.roles enforced via ReportRBACService.assertCanRun; column-level redaction shipped via filterColumns + redactRows with per-column sensitiveColumns map (e.g. police-register.passportOrIdNumber → COMPLIANCE_OFFICER only). EPIC body's AC-6 "Finance reports invisible to Housekeeping" is technically met (RBAC gate returns 403; catalog could omit). Not verified: does GET /catalog filter out reports the caller cannot run?

Code locations:

  • hmp-analytics-service/src/reporting/controller/reports-v2.controller.ts — GET /reports/v2/catalog
  • hmp-analytics-service/src/reporting/registry/report.registry.ts [CITED-BUT-NOT-FOUND] — in-memory ReportRegistry
  • hmp-analytics-service/src/reporting/rbac/ (per directory tree) — ReportRBACService
  • hmp-analytics-service/src/reporting/reports/*.definition.ts — each ReportDefinition declares rbac: { roles: [...], sensitiveColumns: {...} }
  • hmp-web-dashboard/src/app/(dashboard)/reports/v2/page.tsx — Plan 10 catalog card grid (consumer)
  • hmp-web-dashboard/src/components/modules/reports/v2/CatalogList.tsx — catalog card grid component

Unit + integration tests:

  • hmp-analytics-service/src/reporting/controller/reports-v2.controller.spec.ts — covers catalog endpoint
  • Per-definition RBAC specs in each *.definition.spec.ts

Flow tests:

  • none specific to RBAC filtering

Real-backend smokes:

  • none

Use cases referencing this story:

  • none

SME notes:

  • Need to verify whether GET /reports/v2/catalog returns all 63 definitions to every caller, or whether it filters by caller's role. If unfiltered, a Housekeeping user sees the existence of gst-report / payment-collection-report in the catalog card grid and only gets 403 when clicking — the AC-6 letter is met (no data leak) but the spirit (hide what you can't run) is not.
  • The dashboard CatalogList reads from GET /reports/v2/catalog — it does NOT do its own role-filtering. Whatever the API returns is what's shown. So the question is purely on the API side.

Drift findings:

D-011 — [P2] [AC-letter-vs-spirit] EPIC body AC-6 "configurable by Super Admin" suggests dynamic per-tenant role→report mapping. Today the rbac.roles is hardcoded in the ReportDefinition — there is no DB-backed override. A Super Admin cannot grant housekeeping-productivity to a custom OPS_LEAD role without redeploying the analytics-service.

  • [SME-PROPOSED] Add ReportRoleOverride Prisma model (tenantId, reportId, role) UNIQUE; ReportRBACService.assertCanRun consults the override table first, then falls back to definition rbac.roles. Author SUPER_ADMIN-only endpoints POST/DELETE /reports/v2/role-overrides for tenant-level grants.
  • [SME-PROPOSED] Add AC: "GET /reports/v2/catalog SHALL filter the response to only include reports the caller has permission to run (definition rbac.roles ∪ ReportRoleOverride rows for caller's roles). Catalog response carries no information about reports the caller cannot run."

US-012D-03 — PDF Export with Hotel Letterhead​

Catalog status: Not Started (no catalog row) Verified status: Partial — PdfFormatter shipped (Plan 6, 2026-04-30) using Puppeteer + puppeteer-cluster (maxConcurrency=3) + Handlebars templates (<def.id>.hbs first, then generic-tabular.hbs fallback). Requires Chromium in Docker (apk add chromium nss freetype). SKIP_PUPPETEER=true opt-out for CI. EPIC body's hotel-letterhead AC (logo, GSTIN footer, INR comma separators, repeated headers on multi-page) is NOT verified — depends on whether the per-report .hbs templates include those elements. The generic-tabular.hbs fallback most likely does not.

Code locations:

  • hmp-analytics-service/src/reporting/format/pdf.formatter.ts [CITED-BUT-NOT-FOUND] — PdfFormatter class (per analytics-service CLAUDE.md; specific file not opened)
  • hmp-analytics-service/src/reporting/format/formatter.interface.ts — Formatter interface contract
  • hmp-analytics-service/src/reporting/format/templates/*.hbs [CITED-BUT-NOT-FOUND] — Handlebars templates (path inferred)
  • hmp-analytics-service/src/reporting/format/templates/generic-tabular.hbs [CITED-BUT-NOT-FOUND] — fallback template (path inferred)
  • hmp-analytics-service/src/reporting/exports/report-export.service.ts — executeExport delegates to formatter registry
  • hmp-analytics-service/src/reporting/exports/storage/ — S3ExportStorage (presigned URLs, 24-h TTL)
  • hmp-web-dashboard/src/components/modules/reports/v2/ExportButton.tsx — dropdown with PDF/XLSX/CSV items

Unit + integration tests:

  • (No pdf.formatter.spec.ts enumerated; likely exists alongside formatter.interface.ts)
  • hmp-analytics-service/src/reporting/exports/report-export.service.spec.ts — exists
  • hmp-analytics-service/src/reporting/exports/report-exports.controller.spec.ts — exists

Flow tests:

  • none specific to PDF generation

E2E (Playwright):

  • hmp-web-dashboard/e2e/reports-v2.spec.ts — covers PDF trigger from ExportButton (asserts the request fires; does NOT assert PDF content correctness)

Use cases referencing this story:

  • none

SME notes:

  • The PdfFormatter wiring is sound; the gap is template content. Without per-report .hbs templates that include hotel logo + GSTIN footer + INR-comma-separator + repeating header, the AC is unmet for everything but the few reports that have a custom template.
  • 5MB cap on hotel-logo upload exists (per AP-T02-002 / property-asset) — sufficient.
  • Multi-page PDFs need <thead> with CSS display: table-header-group to repeat on every page. Puppeteer respects this by default; depends whether the Handlebars <table> markup uses <thead>.

Drift findings:

D-009 — [P1] [Story-incomplete + India-hospitality-critical] EPIC body AC "PDF includes hotel name + logo + GSTIN footer + INR comma separators + repeating headers" requires per-report Handlebars templates that include those elements. Today (per analytics-service CLAUDE.md) only a generic-tabular.hbs fallback is named — the per-report templates either don't exist or are not enumerated.

  • [SME-PROPOSED] Audit the src/reporting/format/templates/ directory and confirm which of the 63 reports have a custom .hbs. For the rest, enhance generic-tabular.hbs to include:
    • <header> block with hotel name + logo (resolved via PropertyServiceClient.getProperty(propertyId) injected into the Handlebars context)
    • <footer> block with "Page X of Y · Confidential · {hotelName}, {city} · GSTIN: {gstin}" (page numbers via Puppeteer headerTemplate/footerTemplate PDF options)
    • INR comma separator: register Handlebars helper formatINR(value) → ₹ + Intl.NumberFormat('en-IN').format(value) (e.g. ₹48,75,000)
    • <thead> with display: table-header-group so headers repeat on every page
  • [SME-PROPOSED] Add AC: "The default generic-tabular.hbs template SHALL be sufficient for all 63 v2 reports to satisfy the hotel-letterhead AC (per-report custom templates are an optional override, not a requirement)."

US-012D-04 — Excel (.xlsx) Export​

Catalog status: Not Started (no catalog row) Verified status: Built — XlsxFormatter shipped (Plan 6, 2026-04-30) using exceljs with bold + light-grey header row, column widths 16/14 chars, sheet name capped at 31 chars, content-type application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. EPIC body's AC-5 sub-epic AC "valid .xlsx files with a summary tab" — the summary-tab requirement is unmet (only a single sheet today).

Code locations:

  • hmp-analytics-service/src/reporting/format/xlsx.formatter.ts [CITED-BUT-NOT-FOUND] — XlsxFormatter (per analytics-service CLAUDE.md)
  • hmp-analytics-service/src/reporting/format/formatter.interface.ts
  • hmp-analytics-service/src/reporting/reporting.module.ts — registers XlsxFormatter in FORMATTER_REGISTRY

Unit + integration tests:

  • (Likely xlsx.formatter.spec.ts; not enumerated)

Flow tests:

  • none

E2E (Playwright):

  • e2e/reports-v2.spec.ts covers ExportButton dropdown including XLSX item (asserts request fires)

SME notes:

  • Indian number formatting (lakhs/crores) requires exceljs cell numFmt = '##,##,##0.00' (Indian grouping). Standard '#,##0.00' produces US-grouped numbers (1,234,567) instead of Indian (12,34,567). Quick win.
  • Auto-filter (worksheet.autoFilter = 'A1:Zn') not enabled today per US-012-006 review — recommendation to add.

Drift findings:

(no new drift specific to this story; rolls into D-009-equivalent for XLSX formatting; carried via SME-PROPOSED below)

  • [SME-PROPOSED] Add a "Summary" tab to multi-section reports: tab 1 = Summary (totals + KPIs), tab 2 = Detail (current single-sheet content). Add def.xlsxSummary?: (rows) => SummaryBlock[] optional callback on ReportDefinition.
  • [SME-PROPOSED] Set Indian number format on currency columns: numFmt = '##,##,##0.00' (lakhs/crores grouping). Add def.schema.measures[].locale = 'IN' flag drives this.
  • [SME-PROPOSED] Enable auto-filter on the header row: worksheet.autoFilter = 'A1:<lastCol><lastRow>'.

US-012D-05 — Report Run History Log​

Catalog status: Not Started (no catalog row) Verified status: Partial — backend audit-trail shipped (ReportAuditService writes ReportRun rows on every execution; GET /reports/v2/:id/runs lists; 5-year retention for CERT-In + GST audit compliance per analytics-service CLAUDE.md). EPIC body's AC "Run History UI" (with status + duration + download link per run) — dashboard surface NOT shipped for either on-demand runs or scheduled runs.

Code locations:

  • hmp-analytics-service/src/reporting/audit/report-audit.service.ts [CITED-BUT-NOT-FOUND] — ReportAuditService (per CLAUDE.md)
  • hmp-analytics-service/src/reporting/controller/reports-v2.controller.ts — GET /reports/v2/:id/runs endpoint
  • hmp-analytics-service/prisma/schema.prisma [CITED-BUT-NOT-FOUND] — ReportRun model (writes userId, filters, format, IP, UA, duration, rowCount, presigned export URL)
  • (no dashboard page exists — see D-005)

Unit + integration tests:

  • (Per-component report-audit.service.spec.ts likely exists; not enumerated)

Flow tests:

  • none

SME notes:

  • The backend retention contract (5 years) is excellent for compliance (CERT-In + GST audit). What's missing is the UI surface so an operator + auditor can SEE the trail without writing SQL.
  • The same /reports/v2/:id/runs endpoint serves both manual-run history AND scheduled-run history (a scheduled fire writes a ReportRun row exactly like a manual run). One UI can serve both.

Drift findings:

(captured under D-005 dashboard-surface gap)

  • [SME-PROPOSED] Author /reports/v2/[reportId]/runs page in the dashboard: paginated table (timestamp, user, format, rowCount, durationMs, status, download-link). Filter by date-range + user + status. Default 30-day window. Powered by useReportRuns(reportId, filter) against existing GET /reports/v2/:id/runs.
  • [SME-PROPOSED] On /reports/scheduled (per D-005), each schedule row links to its RunHistory view filtered to triggerSource=SCHEDULED, scheduleId=<id>. Add triggerSource + scheduleId columns to ReportRun if not present (migration).

US-012D-06 — Save and Share Custom Report Templates​

Catalog status: Not Started (no catalog row) Verified status: Partial — Plan 3.4 ReportSavedView shipped (POST/GET/GET:id/PATCH/DELETE /reports/v2/saved-views + dashboard pages). Owner-or-ADMIN access; ADMIN+ required to set shared=true; non-owner accessing private view → 404 (not 403, prevents enumeration). EPIC body's AC "Share custom report with Finance Manager role" is met at the tenant level (any user in tenant can see shared views) — but not at the role level (cannot scope shared to specific roles).

Code locations:

  • (same as US-012-16 — saved-views surfaces)
  • hmp-analytics-service/src/reporting/saved-views/saved-views.controller.ts:38-178
  • hmp-analytics-service/src/reporting/saved-views/saved-views.service.ts
  • hmp-analytics-service/src/reporting/saved-views/dto/saved-view.dto.ts
  • hmp-web-dashboard/src/app/(dashboard)/reports/v2/saved/page.tsx
  • hmp-web-dashboard/src/components/modules/reports/v2/SaveViewModal.tsx
  • hmp-web-dashboard/src/components/modules/reports/v2/PinnedReportsWidget.tsx

Unit + integration tests:

  • hmp-analytics-service/src/reporting/saved-views/saved-views.controller.spec.ts
  • hmp-analytics-service/src/reporting/saved-views/saved-views.service.spec.ts

Flow tests:

  • saved-views-schedules.flow.spec.ts :: S1+S2+S3+S4+S5+S8 (covers private create, list, update, pin filter, shared create by admin, delete)

E2E (Playwright):

  • (transitively via reports-v2.spec.ts)

SME notes:

  • "Share with Finance Manager role" is the EPIC body's example — but ReportSavedView.shared is a boolean (all-tenant or owner-only). Real-world usage from the GM persona: "share with Finance team + Owner, but NOT with Housekeeping". Today's binary shared flag does not express this; the only escape hatch is per-role RBAC at the ReportDefinition level (which doesn't help — the saved view is a (reportId, filters) combo).
  • Pinned-to-home is the right primitive for GM + Finance Manager dashboards — they have a "what reports matter to me today" surface (PinnedReportsWidget on /). Discoverability good.

Drift findings:

(rolls into D-001 numbering drift + the role-scoped sharing gap below)

  • [SME-PROPOSED] Replace shared: boolean with visibility: 'PRIVATE' | 'SHARED_ROLES' | 'SHARED_TENANT' + sharedRoles?: string[]. Migration sets existing shared=true rows to visibility='SHARED_TENANT'. PATCH endpoint accepts new shape.
  • [SME-PROPOSED] Add AC: "When visibility='SHARED_ROLES', only users whose JWT roles intersect with sharedRoles[] may see/run the saved view. ADMIN+ may always modify."