Files
pn-new-crm/docs/reports-page-design.md
Matt c7dbe0bb10 docs: lock Reports page + Tenancies module designs
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>
2026-05-25 03:54:32 +02:00

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 /reports surface gated by reports.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 }brandingPortId lets the report use another port's logo/colour on the cover (admin-only).
  • config.kindOptions — per-kind option bag; e.g. for website-analytics the country filter, for client-summary the 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:

  1. Created via the builder ("Schedule recurring" panel) or /schedules page.
  2. BullMQ cron checks every 15 min for enabled=true AND next_run_at <= now().
  3. For each match: create a report_runs row (triggered_by='schedule'), enqueue the rendering job, then advance next_run_at based on cadence.
  4. 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 for super_admin, director, sales_manager, sales_agent, finance_manager. OFF for viewer, residential_partner.
  • reports.admin — manage BOTH team-shared templates AND schedules. Default ON for super_admin only.

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 by src/jobs/processors/report-render.ts. Steps:

    1. Resolve the run's config + storage key.
    2. Run kind-specific resolver (already wired for dashboard and website-analytics; new ones get a registry entry).
    3. Render to outputFormat (PDF via existing pdfme+pdf-lib path; CSV via shared resolver-to-csv helper; PNG/JPEG via puppeteer-snapshot of each chart).
    4. Upload to storage, update report_runs row with storage_key, size_bytes, status='complete'.
    5. If triggered_by='schedule' (schedule has recipients) — enqueue reports-email follow-up.
  • reports-email — fan-out email delivery. Consumed by src/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:

  1. SELECT id FROM report_schedules WHERE enabled = TRUE AND next_run_at <= now() ORDER BY next_run_at.
  2. For each: create the report_runs row + enqueue reports-render + UPDATE next_run_at based on cadence (helpers in src/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_9 etc).
  • Per-user template visibility ('shared with specific users' beyond port-wide team).

Capture in docs/BACKLOG.md after P5 ships.