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.mdper./PLAYBOOK.md. Source of truth for "% passing":hmp-analytics-service/CLAUDE.md+ per-service CLAUDE.md Gotchas tables (per platformCLAUDE.mdAudit 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
| Status | Meaning |
|---|---|
Built | Code exists + tests exist + tests pass |
Built-Untested | Code exists, no automated test coverage (or test exists but is a stub) |
Partial | Code exists for some acceptance criteria, gaps for others |
Stub | Code is a placeholder / TODO / returns mock data |
Not Started | No code |
Summary
| Metric | Count |
|---|---|
| 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-Built | 0 |
| Built-Untested | 0 |
| Partial | 5 (US-012-16, US-012D-01, US-012D-02, US-012D-03, US-012D-06) |
| Stub | 1 (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 Started | 2 (US-012-20 if interpreted per EPIC body's Sprint-4 acceptance criteria; US-012D-05 — no RuleRunHistory-equivalent UI for scheduled reports) |
| Drift findings | 11 (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] refinements | 26 (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 viaReportDefinitionsRegistrar)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/schedulehmp-analytics-service/src/reporting/saved-views/saved-views.service.ts— owner-or-shared visibility, ADMIN+ required to setshared=truehmp-analytics-service/src/reporting/saved-views/dto/saved-view.dto.ts— Create/Update/Schedule DTOshmp-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 runhmp-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 gridhmp-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 pagehmp-web-dashboard/src/app/(dashboard)/reports/v2/saved/_components/EditSavedViewDialog.tsx+ScheduleSavedViewDialog.tsx— W3.D.1 (2026-05-07) edit + schedule dialogshmp-web-dashboard/src/components/modules/reports/v2/SaveViewModal.tsx— Plan 3.4 shadcn Dialog (fields: name, pinnedToHome, shared visible to ADMIN+ only) perhmp-web-dashboard/CLAUDE.mdhmp-web-dashboard/src/components/modules/reports/v2/PinnedReportsWidget.tsx— home-page widget for pinned viewshmp-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 in2026-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/v2not present per directory tree)
E2E (Playwright):
hmp-web-dashboard/e2e/reports-v2.spec.ts— 9 specs perhmp-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 oldReportsModulebut did NOT delete the olderReportBuilderModule. That module's in-memoryMap<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/scheduleendpoint 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
SaveViewModaldoes 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-026to the catalog for the EP-012D-prefixed stories AND adoptUS-012-021 Custom Report Builder/US-012-022 Scheduled Report Deliveryto 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 newUS-012-021..US-012-024for 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.tsto the nextXX-flow-demo-readiness.mdsnapshot 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.yamlcovering: given a saved view named "Q1 Channel Mix", when a user PATCHes it withcron='0 8 * * 1'+channels=[EMAIL]+format=PDF, then a BullMQ recurring job is registered AND every fire delivers the PDF viaEmailChannelto 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 allactiveschedules at boot; dedupe key =scheduleId;SKIP_REPORT_SCHEDULER=trueopt-out)hmp-analytics-service/src/reporting/scheduler/scheduler.module.ts— module wires service + BullMQ depshmp-analytics-service/src/reporting/distribution/distribution.service.ts— aggregator dispatching to all 4 channels in parallel; partial failures capturedhmp-analytics-service/src/reporting/distribution/email.channel.ts+.spec.ts— EmailChannel: posts to notification-service/notifications/email/sendviaNotificationHttpClient, attaches presigned S3 link when availablehmp-analytics-service/src/reporting/distribution/whatsapp.channel.ts+.spec.ts— WhatsappChannel: whitelist restricted tomanager-flash-report,revenue-daily-report,arrival-report,departure-report; throwsUnsupportedChannelErrorfor othershmp-analytics-service/src/reporting/distribution/s3.channel.ts+.spec.ts— S3Channel: reuses Plan 6S3ExportStorage; pass-through ifreport.urlalready set; no-op when S3 not configuredhmp-analytics-service/src/reporting/distribution/sftp.channel.ts+.spec.ts— SftpChannel: ssh2-sftp-client, readsTenantConfig(category=SFTP, key=primary), 3 retries + exponential backoffhmp-analytics-service/src/reporting/distribution/notification-http.client.ts— thin HTTP wrapper for notification-service (POST/notifications/email/send+/notifications/whatsapp/send) — see D-008hmp-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 CRUDhmp-analytics-service/prisma/schema.prisma[CITED-BUT-NOT-FOUND]—ReportSchedulemodel:reportId,cron(POSIX),timezone(IANA, default Asia/Kolkata),channelsJSON (DeliveryChannelConfig[]),filtersJSON,active,failureCount,lastFailureMsg(per analytics-service CLAUDE.md, table not opened directly)hmp-analytics-service/prisma/schema.prisma[CITED-BUT-NOT-FOUND]—DistributionListmodel: tenant-scoped named groups,membersJSON[{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-timefindMany({active:true})+registerSchedulehmp-analytics-service/src/reporting/distribution/distribution.service.spec.ts— aggregator behaviour, partial-failure handlinghmp-analytics-service/src/reporting/distribution/distribution-e2e.spec.ts— end-to-end distribution pathhmp-analytics-service/src/reporting/distribution/email.channel.spec.ts,whatsapp.channel.spec.ts,s3.channel.spec.ts,sftp.channel.spec.ts— per-channel coveragehmp-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.tsif 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 incrementsfailureCountandlastFailureMsgbut 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/Kolkatadefault 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 BullMQdefaultJobOptions: { attempts: 2, backoff: { type: 'fixed', delay: 15 * 60 * 1000 } }. Wire afailedevent handler that, on second failure, emitsreport.scheduled.failedKafka 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-recordalert SHALL fire to the tenant ADMIN within 5 minutes (viareport.scheduled.failedKafka 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/scheduledwith: 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/savedin the sidebar Reports group. - [SME-PROPOSED] Add AC: "An operator with role MANAGER+ SHALL be able to navigate to
/reports/scheduled, see everyReportSchedulefor 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 aLink: </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-parservalidation on the DTO:@IsString() @Validate(IsValidCron). Reject 400 at controller boundary with messagecron 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(invokesdef.query) +LiveProxyResolver(calls source services viaSERVICE_CLIENT_MAP_TOKEN)hmp-analytics-service/src/reporting/reports/*.definition.ts× 63 — each is a typedReportDefinitionfactory withschema(dimensions + measures),query(Prisma call),rbac(roles + sensitiveColumns),comparative(LY/Budget/Forecast),drillDownhmp-analytics-service/src/reporting/controller/reports-v2.controller.ts:18-41—validatePeriodguard at API boundary (added 2026-05-16) rejects malformed/missingfilters.periodwith structured 400hmp-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.
ReportExecutionServicedoes not wrapdef.queryinPromise.raceagainst 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.queryMUST includeWHERE "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 inPromise.race([resolver(), timeoutPromise(30_000)]); on timeout, throwReportTimeoutError→ controller returns 504 Gateway Timeout with{code:'REPORT_TIMEOUT', timeoutMs:30000}. - [SME-PROPOSED] Add an
EXPLAIN ANALYZE-driven row-estimate gate (or a hardLIMIT 100000on everydef.queryreturning > 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 viaReportDefinitionsRegistrar.register) that fails the boot if any registered definition's query string does not include a tenant filter. Alternatively, refactor the resolver layer sodef.queryreceives a pre-boundprisma.<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
tenantIdfilters 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/cataloghmp-analytics-service/src/reporting/registry/report.registry.ts[CITED-BUT-NOT-FOUND]— in-memoryReportRegistryhmp-analytics-service/src/reporting/rbac/(per directory tree) —ReportRBACServicehmp-analytics-service/src/reporting/reports/*.definition.ts— eachReportDefinitiondeclaresrbac: { 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/catalogreturns all 63 definitions to every caller, or whether it filters by caller's role. If unfiltered, a Housekeeping user sees the existence ofgst-report/payment-collection-reportin 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
CatalogListreads fromGET /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
ReportRoleOverridePrisma model(tenantId, reportId, role)UNIQUE;ReportRBACService.assertCanRunconsults the override table first, then falls back to definitionrbac.roles. Author SUPER_ADMIN-only endpointsPOST/DELETE /reports/v2/role-overridesfor tenant-level grants. - [SME-PROPOSED] Add AC: "
GET /reports/v2/catalogSHALL filter the response to only include reports the caller has permission to run (definitionrbac.roles∪ReportRoleOverriderows 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]—PdfFormatterclass (per analytics-service CLAUDE.md; specific file not opened)hmp-analytics-service/src/reporting/format/formatter.interface.ts—Formatterinterface contracthmp-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—executeExportdelegates to formatter registryhmp-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.tsenumerated; likely exists alongsideformatter.interface.ts) hmp-analytics-service/src/reporting/exports/report-export.service.spec.ts— existshmp-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
.hbstemplates 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 CSSdisplay: table-header-groupto 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, enhancegeneric-tabular.hbsto include:<header>block with hotel name + logo (resolved viaPropertyServiceClient.getProperty(propertyId)injected into the Handlebars context)<footer>block with "Page X of Y · Confidential · {hotelName}, {city} · GSTIN: {gstin}" (page numbers via PuppeteerheaderTemplate/footerTemplatePDF options)- INR comma separator: register Handlebars helper
formatINR(value)→₹+Intl.NumberFormat('en-IN').format(value)(e.g.₹48,75,000) <thead>withdisplay: table-header-groupso headers repeat on every page
- [SME-PROPOSED] Add AC: "The default
generic-tabular.hbstemplate 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.tshmp-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.tscovers ExportButton dropdown including XLSX item (asserts request fires)
SME notes:
- Indian number formatting (lakhs/crores) requires
exceljscellnumFmt = '##,##,##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 onReportDefinition. - [SME-PROPOSED] Set Indian number format on currency columns:
numFmt = '##,##,##0.00'(lakhs/crores grouping). Adddef.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/runsendpointhmp-analytics-service/prisma/schema.prisma[CITED-BUT-NOT-FOUND]—ReportRunmodel (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.tslikely 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/runsendpoint serves both manual-run history AND scheduled-run history (a scheduled fire writes aReportRunrow 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]/runspage in the dashboard: paginated table (timestamp, user, format, rowCount, durationMs, status, download-link). Filter by date-range + user + status. Default 30-day window. Powered byuseReportRuns(reportId, filter)against existingGET /reports/v2/:id/runs. - [SME-PROPOSED] On
/reports/scheduled(per D-005), each schedule row links to itsRunHistoryview filtered totriggerSource=SCHEDULED, scheduleId=<id>. AddtriggerSource+scheduleIdcolumns toReportRunif 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-178hmp-analytics-service/src/reporting/saved-views/saved-views.service.tshmp-analytics-service/src/reporting/saved-views/dto/saved-view.dto.tshmp-web-dashboard/src/app/(dashboard)/reports/v2/saved/page.tsxhmp-web-dashboard/src/components/modules/reports/v2/SaveViewModal.tsxhmp-web-dashboard/src/components/modules/reports/v2/PinnedReportsWidget.tsx
Unit + integration tests:
hmp-analytics-service/src/reporting/saved-views/saved-views.controller.spec.tshmp-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.sharedis 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 binarysharedflag does not express this; the only escape hatch is per-role RBAC at theReportDefinitionlevel (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 (
PinnedReportsWidgeton/). Discoverability good.
Drift findings:
(rolls into D-001 numbering drift + the role-scoped sharing gap below)
- [SME-PROPOSED] Replace
shared: booleanwithvisibility: 'PRIVATE' | 'SHARED_ROLES' | 'SHARED_TENANT'+sharedRoles?: string[]. Migration sets existingshared=truerows tovisibility='SHARED_TENANT'. PATCH endpoint accepts new shape. - [SME-PROPOSED] Add AC: "When
visibility='SHARED_ROLES', only users whose JWT roles intersect withsharedRoles[]may see/run the saved view. ADMIN+ may always modify."