docs/reports-page-design.md: ~400 lines covering
- Routing: /{portSlug}/reports landing + builder/templates/runs/schedules
- 3 new tables (report_templates_shared, report_runs, report_schedules)
with full schema + indexes
- API surface (12 routes) gated on reports.export / reports.admin
- BullMQ queues (reports-render, reports-email) + cron scheduler
- UI plan for landing + two-panel builder + 3 sub-pages
- Quick-path dashboard button rewire
- 7-PR phased plan (~43h total)
docs/tenancies-design.md: ~350 lines covering
- Vocabulary split (Reservation vs Tenancy)
- Platform-wide module-enabled rule (auto-flips on first insert,
admin Operations toggle, warning on disable)
- Rename migration berth_reservations -> tenancies + self-FKs
- Tenure-type behaviour matrix (renewals + public-map flip)
- Transfer flow (end + mint linked rows)
- 3 new perms (view/manage/cancel)
- Webhook auto-create branch (gated)
- Public-map status precedence (permanent-class only)
- Sidebar entry + top-level page + entity-tab CTAs
- All 4 reporting widgets (module-gated)
- Service layer additions
- API surface (10 routes)
- 7-PR phased plan (~42h total)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
Reports Page Design (/{portSlug}/reports)
Status: Design doc. All Q-block decisions locked 2026-05-24 via AskUserQuestion in the alpha UAT master doc. Implementation phased into discrete PRs at the end.
Goals & non-goals
Goals
- Promote PDF report generation from a cramped dashboard dialog (~25 widgets and growing) to a dedicated landing + builder page.
- Support saved-template management (rename / archive / share-with-team / duplicate).
- Add run history so reps can answer "send me the same report Sarah ran last month."
- Scheduled recurring reports (weekly / monthly / quarterly) with per-recipient email delivery.
- One-click "Generate & email" alongside "Generate & download."
- CSV + PNG/JPEG chart-snapshot outputs alongside the existing PDF.
- Per-report metadata overrides: title, subtitle, cover-page branding swap.
Non-goals (v1)
- Excel workbook output (
xlsx) — defer; PDF + CSV cover the asks. - Public hosted-HTML share-link to a report — defer.
- Cover-page intro paragraph + footer/sign-off — defer; title/subtitle is enough.
- A separate "Reports admin" page; admin controls live alongside the same
/reportssurface gated byreports.admin.
Routing
/{portSlug}/reports
├── (default view) Landing: every report kind as a card with "Generate" CTA + the port's saved templates
├── /[kind] Per-report-kind builder (two-panel: sections checklist + live preview)
├── /templates Shared-templates manager (rename / archive / duplicate / share)
├── /runs Run history (re-run / re-email)
└── /schedules Active recurring schedules (pause / edit recipients / cadence)
The existing dashboard "Export as PDF" button is rewired to navigate to /{portSlug}/reports/dashboard?range=YYYY-MM-DD..YYYY-MM-DD with the active date range pre-filled. One-click access preserved; rep lands in the full builder with everything pre-selected and the PDF preview ready.
Data model
Three new tables.
report_templates_shared
Per-port, port-scoped, optionally shared with the whole team.
CREATE TABLE report_templates_shared (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
-- The report-kind union ('dashboard' | 'website-analytics' | 'client-summary' | 'interest-summary' | 'berth-spec' | 'occupancy' | …).
-- Same vocabulary the existing PDF exporter uses.
kind text NOT NULL,
-- Widget selection + per-widget option overrides + report metadata.
config jsonb NOT NULL,
-- 'private' = creator only; 'team' = anyone with reports.export at this port.
visibility text NOT NULL DEFAULT 'private',
created_by text NOT NULL REFERENCES "user"(id) ON DELETE RESTRICT,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX report_templates_shared_port_kind_idx ON report_templates_shared(port_id, kind);
CREATE INDEX report_templates_shared_port_visibility_idx ON report_templates_shared(port_id, visibility);
Notes:
config.sections: string[]— widget ids, same shape as today's dialog.config.dateRange: { from?: string, to?: string, mode?: 'last_7' | 'last_30' | 'last_90' | 'custom' }— saved templates default to relative ranges so a "Weekly snapshot" template stays fresh.config.metadata: { title?: string, subtitle?: string, brandingPortId?: string }—brandingPortIdlets the report use another port's logo/colour on the cover (admin-only).config.kindOptions— per-kind option bag; e.g. forwebsite-analyticsthe country filter, forclient-summarythe client-id.- Partial unique on
(port_id, lower(name)) where archived_at is null— no two active templates share a name per port.
report_runs
Append-only audit log of every generated report.
CREATE TABLE report_runs (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
-- Nullable: ad-hoc runs (no template) still get logged.
template_id text REFERENCES report_templates_shared(id) ON DELETE SET NULL,
schedule_id text REFERENCES report_schedules(id) ON DELETE SET NULL,
kind text NOT NULL,
config jsonb NOT NULL, -- snapshotted at run time so re-runs reproduce identically
output_format text NOT NULL, -- 'pdf' | 'csv' | 'png' | 'jpg'
-- Storage key of the rendered artefact. Same backend as files (s3 or filesystem).
storage_key text,
size_bytes integer,
status text NOT NULL DEFAULT 'pending', -- 'pending' | 'rendering' | 'complete' | 'failed'
error_message text,
triggered_by text NOT NULL, -- 'user' | 'schedule'
triggered_by_user_id text REFERENCES "user"(id) ON DELETE SET NULL,
-- When non-null, this run was emailed to these recipients on completion.
emailed_to jsonb, -- Array<{ name?: string, email: string }>
emailed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE INDEX report_runs_port_created_idx ON report_runs(port_id, created_at DESC);
CREATE INDEX report_runs_port_user_idx ON report_runs(port_id, triggered_by_user_id);
CREATE INDEX report_runs_port_template_idx ON report_runs(port_id, template_id) WHERE template_id IS NOT NULL;
report_schedules
CREATE TABLE report_schedules (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
template_id text NOT NULL REFERENCES report_templates_shared(id) ON DELETE CASCADE,
-- 'weekly_monday_9' | 'monthly_first_9' | 'quarterly_first_9' to start; cron string optional later.
cadence text NOT NULL,
recipients jsonb NOT NULL, -- Array<{ name?: string, email: string }>
output_format text NOT NULL DEFAULT 'pdf',
enabled boolean NOT NULL DEFAULT true,
last_run_at timestamptz,
next_run_at timestamptz NOT NULL, -- pre-computed for the BullMQ scheduler
created_by text NOT NULL REFERENCES "user"(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX report_schedules_port_enabled_next_idx ON report_schedules(port_id, enabled, next_run_at);
A schedule lifecycle:
- Created via the builder ("Schedule recurring" panel) or
/schedulespage. - BullMQ cron checks every 15 min for
enabled=true AND next_run_at <= now(). - For each match: create a
report_runsrow (triggered_by='schedule'), enqueue the rendering job, then advancenext_run_atbased on cadence. - Rendering job completes → email job fires with the storage key.
API surface (/api/v1/reports/*)
| Verb | Path | Permission | Notes |
|---|---|---|---|
| POST | /api/v1/reports/generate |
reports.export |
One-shot generate. Body: { kind, config, outputFormat?, deliverTo?: { recipients[] } }. Returns { runId, downloadUrl } (presigned) or fires email job when deliverTo set. |
| GET | /api/v1/reports/templates |
reports.export |
Lists templates visible to the caller (own private + team-shared). |
| POST | /api/v1/reports/templates |
reports.export |
Create a template (visibility defaults to private). |
| PATCH | /api/v1/reports/templates/[id] |
reports.export* |
Update name / description / config. * Only the creator OR holders of reports.admin can edit team-shared templates. |
| DELETE | /api/v1/reports/templates/[id] |
reports.admin |
Soft-delete (sets archived_at). Frontend uses "Archive" copy. |
| POST | /api/v1/reports/templates/[id]/duplicate |
reports.export |
Returns a copy owned by caller, visibility=private. |
| GET | /api/v1/reports/runs |
reports.export |
Run history. Filter params: templateId, userId, kind, from, to. |
| POST | /api/v1/reports/runs/[id]/re-run |
reports.export |
Generates a fresh run with the original snapshotted config + same recipients (when triggered_by=schedule). |
| GET | /api/v1/reports/runs/[id]/download |
reports.export |
Presigned URL for the run artefact. |
| GET | /api/v1/reports/schedules |
reports.admin |
List scheduled jobs. |
| POST | /api/v1/reports/schedules |
reports.admin |
Create a schedule. |
| PATCH | /api/v1/reports/schedules/[id] |
reports.admin |
Pause / edit / change recipients. |
| DELETE | /api/v1/reports/schedules/[id] |
reports.admin |
Remove. |
| GET | /api/v1/reports/availability?kind=...&... |
reports.export |
Lightweight per-widget presence check (drives the empty-state pills in the builder; already speced in B2 audit). |
Existing POST /api/v1/reports/generate stays — it's the foundation. New endpoints layer on top.
Permissions
Two perms (locked decision):
reports.export— generate + download + manage own private templates. Default ON forsuper_admin,director,sales_manager,sales_agent,finance_manager. OFF forviewer,residential_partner.reports.admin— manage BOTH team-shared templates AND schedules. Default ON forsuper_adminonly.
Seed via src/lib/db/seed-permissions.ts in the same PR that adds the schema.
BullMQ queue + cron handler
Two new queues:
-
reports-render— per-run render job. Consumed bysrc/jobs/processors/report-render.ts. Steps:- Resolve the run's config + storage key.
- Run kind-specific resolver (already wired for
dashboardandwebsite-analytics; new ones get a registry entry). - Render to
outputFormat(PDF via existingpdfme+pdf-libpath; CSV via shared resolver-to-csv helper; PNG/JPEG via puppeteer-snapshot of each chart). - Upload to storage, update
report_runsrow withstorage_key,size_bytes,status='complete'. - If
triggered_by='schedule'(schedule has recipients) — enqueuereports-emailfollow-up.
-
reports-email— fan-out email delivery. Consumed bysrc/jobs/processors/report-email.ts. Uses existing transactional-email infra (sendBrandedEmail) with the run artefact as an attachment OR a 7-day signed link when over the per-port attachment threshold.
A cron-style reports-scheduler BullMQ recurring job fires every 15 min:
SELECT id FROM report_schedules WHERE enabled = TRUE AND next_run_at <= now() ORDER BY next_run_at.- For each: create the
report_runsrow + enqueuereports-render+ UPDATEnext_run_atbased on cadence (helpers insrc/lib/services/report-schedule.service.ts).
UI plan
1. Landing — /{portSlug}/reports
Two-column layout:
- Left rail: report-kind cards (Dashboard, Website Analytics, Client Summary, Interest Summary, Berth Spec, Occupancy, …). Each card shows last-run timestamp + "Generate" CTA opening that kind's builder.
- Right column: tabs for "My templates" (private), "Team templates" (shared), "Recent runs" (last 10).
Filtered by reports.export/reports.admin so a viewer never sees the page at all.
2. Builder — /{portSlug}/reports/[kind]
Full-page two-panel layout (the locked Q2 shape):
┌────────────────────────────────────┬────────────────────────────────┐
│ Title + subtitle inputs │ │
│ Date range picker │ │
│ ─── Sections (grouped by domain) ──│ Live PDF preview │
│ ☑ Summary │ (re-renders on each │
│ ☐ Pipeline │ toggle, debounced 200ms)│
│ ☑ Berths │ │
│ ☑ Lead sources │ │
│ ☐ Operations │ │
│ ─── Output ─────────────────────── │ │
│ ◉ PDF │ │
│ ◯ CSV │ │
│ ◯ PNG (per chart) │ │
│ ─── Delivery ────────────────────── │ │
│ ◯ Download │ │
│ ◯ Email — recipient list │ │
│ ─── Save / Schedule ─────────────── │ │
│ [ Save as template ] [ Schedule…] │ │
└────────────────────────────────────┴────────────────────────────────┘
Per-section row shows the existing "data availability" pill from the B2 audit (ok / no_data / needs_window / partial) plus a drag-handle to reorder (locked Q9 polish).
3. Templates manager — /{portSlug}/reports/templates
Table of every visible template with columns: name · kind · visibility · last-used · created-by. Row actions: Open in builder · Rename · Duplicate · Share with team (gated on reports.admin for shared ones) · Archive.
4. Run history — /{portSlug}/reports/runs
Server-paginated table. Columns: when · who · template name · kind · format · status · size · re-run / re-email / download.
5. Schedules — /{portSlug}/reports/schedules
Table of active schedules. Columns: template · cadence · recipients · last run · next run · enabled toggle · edit.
Quick-path dashboard button
The existing <ExportDashboardPdfButton> (src/components/reports/export-dashboard-pdf-button.tsx) is rewired to navigate to /{portSlug}/reports/dashboard?range=... instead of opening the in-dashboard dialog. The dialog logic moves into the builder page wholesale (same checklist + same preview component). One-click access preserved; the bigger surface gives reps room to breathe.
Phased PR plan
| PR | Scope | Effort | Ships independently |
|---|---|---|---|
| P1: Schema + perms | 0084_reports_page.sql (3 tables + indexes) + seed reports.export / reports.admin perms + service skeleton (report-template.service.ts, report-run.service.ts, report-schedule.service.ts). No UI changes. |
~4 h | Yes (no behavioural change) |
| P2: Templates API | CRUD routes for report_templates_shared + report_runs (read-only at this stage). Mount under /api/v1/reports/templates + /api/v1/reports/runs. Vitest coverage. |
~4 h | Yes |
| P3: Schedules API + cron | /api/v1/reports/schedules CRUD + BullMQ reports-scheduler recurring job + reports-render + reports-email queues. Renderer reuses the existing PDF path. Vitest + integration tests. |
~8 h | Yes |
| P4: Landing + builder UI | /{portSlug}/reports landing + /[kind] builder. Migrate the existing dialog UI into the builder; delete the dialog. Dashboard button rewires to the builder. |
~10 h | Yes (templates/runs UIs still missing — they get a placeholder) |
| P5: Templates + Runs + Schedules pages | Three sub-route pages, table UIs, row actions, modal forms for "Schedule…". | ~8 h | Yes |
| P6: CSV + PNG outputs | Add output-format renderers; wire output radio in builder. | ~6 h | Yes |
| P7: Metadata overrides + branding swap | Title/subtitle inputs + cover-page brand picker (admin-only). | ~3 h | Yes |
Total: ~43 h spread across 7 PRs.
Open follow-ups (intentionally deferred past v1)
- Excel workbook output.
- Public hosted-HTML share-link (write to
/api/public/reports/[id]with a signed token). - Cover-page intro paragraph + footer/sign-off note.
- Custom cron strings (today: enum cadence only —
weekly_monday_9etc). - Per-user template visibility ('shared with specific users' beyond port-wide team).
Capture in docs/BACKLOG.md after P5 ships.