Compare commits
230 Commits
1a2d2dd1e1
...
a8607ecc9e
| Author | SHA1 | Date | |
|---|---|---|---|
| a8607ecc9e | |||
| 3c2826635d | |||
| 2a2673e328 | |||
| 66869c9a90 | |||
| 709ef350ff | |||
| 4182652d49 | |||
| a77b3c670a | |||
| e933e32dbd | |||
| fd2c7d6b12 | |||
| d556bb88f7 | |||
| bded8b21f1 | |||
| 81d4e64f69 | |||
| 465650957b | |||
| b966d8106d | |||
| f86f511e7b | |||
| c44d818144 | |||
| 080e1fa454 | |||
| 233129f91a | |||
| 905852b8a5 | |||
| 6b28459c45 | |||
| b10bf9bf8e | |||
| 1a65e02885 | |||
| 0fe3e984d1 | |||
| e11529ffcc | |||
| 05b57abf05 | |||
| 12e22d9be3 | |||
| bd432fc6c7 | |||
| adebd5f91d | |||
| 4d1fbcd469 | |||
| b1dfec09a0 | |||
| 7bf587de90 | |||
| 33d0426911 | |||
| 3dc4c6ff14 | |||
| ebdd8408bf | |||
| 93399ea27e | |||
| 7370b2cd7d | |||
| 19002f4c21 | |||
| b4e502fedd | |||
| 2496911dc4 | |||
| 72237a0191 | |||
| b2c8ed2ff1 | |||
| bc54ea2c3e | |||
| c8ea9ec0a0 | |||
| ecf49be18c | |||
| 0ea8d94d26 | |||
| f183f58b0c | |||
| b397f6049d | |||
| a8dec0bada | |||
| 689a114aba | |||
| eab30c194a | |||
| c1fcc9d5c4 | |||
| 0df761f4ad | |||
| 153f6ac797 | |||
| a49ee1c347 | |||
| 4233aa3ac3 | |||
| b2588ecdd8 | |||
| bb9b5bb1a3 | |||
| 544b129b00 | |||
| 28c788ff41 | |||
| 7675a26889 | |||
| 4ae34dacda | |||
| 8a8cff4c4c | |||
| 96c6b7c01c | |||
| 6ca94ee3f1 | |||
| d1c9469fa7 | |||
| 4329db7fc3 | |||
| ba1db2afea | |||
| d0a3a054b6 | |||
| 75920a2540 | |||
| 9868c68f8f | |||
| 100beb9974 | |||
| 3aa1275ed7 | |||
| dda554df84 | |||
| 92975e6bf5 | |||
| 699ae52827 | |||
| 4879b17cff | |||
| 0ab96d74a8 | |||
| 3147923d91 | |||
| 8baf239759 | |||
| 7cc80512da | |||
| 4eefe58cab | |||
| f3aae61ad8 | |||
| 9fac84658a | |||
| ba921d3865 | |||
| 63220ad072 | |||
| a52e92ae3e | |||
| 18b6827b77 | |||
| d8f1c0c34e | |||
| e386c8d83f | |||
| e8a852856e | |||
| 411d0764e8 | |||
| ed2424cc68 | |||
| b7e010ff80 | |||
| 0e4a2d7396 | |||
| 90fbb66709 | |||
| 6517e014a6 | |||
| 73184c51e0 | |||
| 81a98c6695 | |||
| 8416c5f3c3 | |||
| ff0667ce52 | |||
| 9455ff9981 | |||
| a65aadc530 | |||
| ce662071f8 | |||
| a7a008c62e | |||
| acf878f997 | |||
| d3960af340 | |||
| 82049eea92 | |||
| a7d0dd95e2 | |||
| bfed1543b7 | |||
| ad74e4a174 | |||
| 50f48a8b6a | |||
| 16ef609e1b | |||
| 0baca41693 | |||
| a7b72801be | |||
| bdc9c019a8 | |||
| 4b9743a594 | |||
| 660553c074 | |||
| 0ab7055cf1 | |||
| 04a594963f | |||
| 3ffee79f3f | |||
| 638000bb58 | |||
| 1bdc856589 | |||
| 979eadae48 | |||
| de8726a9b9 | |||
| 606bf19fb5 | |||
| eaa01d25f9 | |||
| f9980900b1 | |||
| 880c5cbafc | |||
| 63f96254e5 | |||
| 76a57b1d6f | |||
| d597e158fe | |||
| ad312df8a4 | |||
| 1f41f8a8a0 | |||
| 9a5ba87d6c | |||
| 955911302b | |||
| b5ebed9c36 | |||
| c761b4b911 | |||
| c0e5af8b92 | |||
| 1b00c8a7a2 | |||
| 0804944647 | |||
| ab798947d8 | |||
| 0e8feb1073 | |||
| eceb77a6c4 | |||
| b598740b2a | |||
| ddc7b78895 | |||
| b6f55636ab | |||
| a4c49f5e5a | |||
| 631b5d7ed5 | |||
| 7f85128dc2 | |||
| 13fe3841d1 | |||
| 2129fbdf15 | |||
| 03738bfa9a | |||
| e5e2e68e5d | |||
| d68d8e5a79 | |||
| ae3f483cb6 | |||
| c9f0bdc687 | |||
| dec54806cb | |||
| d2b0d42e84 | |||
| 3037d832c6 | |||
| 8e2e2ea113 | |||
| ee6e3f3f3f | |||
| 0412107d86 | |||
| 4c5dc7ec17 | |||
| 3b34b41989 | |||
| 86a6944d1c | |||
| 64d0ae540b | |||
| 2f3200764a | |||
| a23a9862cc | |||
| b0831a6872 | |||
| eee4f06737 | |||
| 48f6fb94a7 | |||
| 40e3db237d | |||
| 5422f11747 | |||
| 286eb51f81 | |||
| ef63e86fde | |||
| e790ff708b | |||
| cf8bbf3018 | |||
| ae68e384ca | |||
| 92759d03e8 | |||
| 8e06d4549d | |||
| f8fcb8d8ad | |||
| c8e6371793 | |||
| 433ab3bf75 | |||
| 4556a03b8b | |||
| 4dd1fa4b24 | |||
| e6103a4473 | |||
| ebede74ca0 | |||
| bd8bb2e032 | |||
| d904122498 | |||
| dd481e0c7d | |||
| 1b441ca826 | |||
| 104226f967 | |||
| fb4b9c9595 | |||
| f286c4ef5f | |||
| a0ffa1baae | |||
| e9d5df647d | |||
| 1082b80542 | |||
| 830ac39900 | |||
| 4ec0004867 | |||
| 9f3e739c76 | |||
| e9251a399a | |||
| 5c5ab49218 | |||
| 4b31f01a04 | |||
| e6cf50fd46 | |||
| 4a50bab389 | |||
| 5bed62dc72 | |||
| 51a60c1b9e | |||
| 1bfed587b5 | |||
| 72f50b681c | |||
| b93fdadb59 | |||
| da7ce16344 | |||
| 07b5756014 | |||
| 7c25d1aef6 | |||
| 20ee2c1dcf | |||
| 43191659e6 | |||
| 7804e9bb17 | |||
| ee2da8f67e | |||
| 72ab7180cf | |||
| 8fdf7a92cf | |||
| 91b5a41e10 | |||
| 502455ac04 | |||
| aad514a3bd | |||
| 3f86baeb0f | |||
| 19622985b5 | |||
| 82fd75081a | |||
| 3c47f6b7f9 | |||
| e13232e2ad | |||
| 4d6a293534 | |||
| 9b4aabe04b | |||
| e01a87ff2e |
69
.dockerignore
Normal file
69
.dockerignore
Normal file
@@ -0,0 +1,69 @@
|
||||
# Build context exclusions — keep the image small AND prevent secrets
|
||||
# from accidentally leaking into a layer.
|
||||
# The audit caught that the previous absence of this file shipped a
|
||||
# 7.6 GB build context, with .env files reachable via `COPY . .`.
|
||||
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Local env / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Node / pnpm
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.pnpm-debug.log
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Next.js build artifacts (regenerated inside the image)
|
||||
.next
|
||||
out
|
||||
|
||||
# Tooling caches
|
||||
.cache
|
||||
.turbo
|
||||
.eslintcache
|
||||
.vercel
|
||||
.swc
|
||||
|
||||
# OS noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
|
||||
# Testing / coverage
|
||||
coverage
|
||||
.nyc_output
|
||||
test-results
|
||||
playwright-report
|
||||
tests/e2e/visual/snapshots.spec.ts-snapshots/*.png
|
||||
playwright/.cache
|
||||
|
||||
# Project artefacts that don't belong in a runtime image
|
||||
.claude
|
||||
.husky
|
||||
docs
|
||||
AGENTS.md
|
||||
AUDIT-*.md
|
||||
SECURITY-GUIDELINES.md
|
||||
PROMPTS-*.md
|
||||
README.md
|
||||
*.log
|
||||
*.tgz
|
||||
|
||||
# Generated / scratch
|
||||
.serena
|
||||
.superpowers
|
||||
.remember
|
||||
.audit-cache
|
||||
.specstory
|
||||
22
.env.example
22
.env.example
@@ -16,11 +16,31 @@ MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=crm-files
|
||||
MINIO_USE_SSL=false
|
||||
# When `true`, the S3 backend auto-creates the configured bucket on boot if it
|
||||
# does not exist (otherwise boot throws so deployment-time misconfigs surface
|
||||
# immediately). Leave unset in production.
|
||||
MINIO_AUTO_CREATE_BUCKET=false
|
||||
|
||||
# Documenso
|
||||
DOCUMENSO_API_URL=https://documenso.example.com/api/v1
|
||||
# Use the bare host — never include `/api/v1` in this URL. The Documenso
|
||||
# client constructs versioned paths internally based on DOCUMENSO_API_VERSION
|
||||
# below, and a double-pathed URL (https://.../api/v1/api/v1/...) returns 404
|
||||
# on every call. Trailing-slash values are fine.
|
||||
DOCUMENSO_API_URL=https://documenso.example.com
|
||||
# `v1` (Documenso 1.13.x) or `v2` (Documenso 2.x). Determines which API path
|
||||
# prefix the client uses and which response-shape normalizer runs.
|
||||
DOCUMENSO_API_VERSION=v1
|
||||
DOCUMENSO_API_KEY=your-documenso-api-key
|
||||
DOCUMENSO_WEBHOOK_SECRET=your-webhook-secret-min-16-chars
|
||||
# The Documenso template id used by the EOI send pathway. Per-port overrides
|
||||
# live in `system_settings.documenso_template_id_eoi`; this env value is the
|
||||
# global fallback when no per-port row exists.
|
||||
DOCUMENSO_TEMPLATE_ID_EOI=
|
||||
# Recipient role ids on the EOI template. The send service copies the template
|
||||
# layout but re-targets recipients per interest, so we need the role ids to
|
||||
# look up which template recipient becomes the Client / Sales signer.
|
||||
DOCUMENSO_RECIPIENT_ID_CLIENT=
|
||||
DOCUMENSO_RECIPIENT_ID_SALES=
|
||||
|
||||
# Email (SMTP)
|
||||
SMTP_HOST=mail.portnimara.com
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -29,6 +29,8 @@ docker-compose.override.yml
|
||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||
/*.png
|
||||
/*.jpg
|
||||
# Local-only dashboard widget-combo screenshots — regenerated by manual testing
|
||||
/combos/
|
||||
|
||||
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||
/client-portal/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
|
||||
"*.{ts,tsx}": ["eslint --fix", "prettier --write", "node scripts/tsc-staged.mjs"],
|
||||
"*.{json,md,css}": ["prettier --write"]
|
||||
}
|
||||
|
||||
39
CLAUDE.md
39
CLAUDE.md
@@ -88,19 +88,36 @@ src/
|
||||
- **Polymorphic ownership:** Yachts and invoice billing-entities use `<entity>_type` + `<entity>_id` column pairs (`'client' | 'company'`). Resolve owner identity through `src/lib/services/yachts.service.ts` / `eoi-context.ts` rather than reading the columns ad hoc — those services apply the type discriminator.
|
||||
- **EOI generation:** Two pathways share the same `EoiContext` (`src/lib/services/eoi-context.ts`). Documenso pathway calls the template-generate endpoint via `documenso-payload.ts`; in-app pathway fills the same source PDF (`assets/eoi-template.pdf`) via `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm). Routed through `generateAndSign(...)` in `src/lib/services/document-templates.ts` with a `pathway` parameter.
|
||||
- **Merge fields:** Token catalog lives in `src/lib/templates/merge-fields.ts`; the `createTemplateSchema` validator uses `VALID_MERGE_TOKENS` as an allow-list, so unknown tokens are rejected at template creation time.
|
||||
- **Documenso webhooks:** Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the `X-Documenso-Secret` header — there is no HMAC. The receiver at `src/app/api/webhooks/documenso/route.ts` does a timing-safe equality check via `verifyDocumensoSecret`. Event names arrive as the uppercase Prisma enum on the wire (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat.
|
||||
- **Documenso webhooks:** Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the `X-Documenso-Secret` header — there is no HMAC. The receiver at `src/app/api/webhooks/documenso/route.ts` does a timing-safe equality check via `verifyDocumensoSecret`. Event names arrive as the uppercase Prisma enum on the wire (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat. `handleDocumentCompleted` is **idempotent** — early-returns when `doc.status === 'completed' && doc.signedFileId` so Documenso retries on 5xx don't insert duplicate file rows + orphan blobs. The switch handles `DOCUMENT_SIGNED|COMPLETED|REJECTED|DECLINED|OPENED|EXPIRED`, plus v2 aliases `RECIPIENT_VIEWED` / `RECIPIENT_SIGNED` (logged + routed to v1 equivalents).
|
||||
- **Documenso API responses:** 2.x renamed `id` → `documentId` and recipient `id` → `recipientId`; v1.13 still uses `id`. `src/lib/services/documenso-client.ts` runs every response through `normalizeDocument()` which reads either field name and surfaces the legacy `id` form to downstream consumers.
|
||||
- **Documenso v1 vs v2 endpoint routing:** `getPortDocumensoConfig(portId)` resolves the per-port `apiVersion` ('v1' | 'v2'). `documenso-client.ts` exports version-aware wrappers: `getDocument`, `createDocument`, `sendDocument`, `sendReminder`, `downloadSignedPdf`, `voidDocument`, `placeFields`. v2 → `/api/v2/envelope/*` (`create` is multipart with `{payload, files}`; `distribute` returns per-recipient `signingUrl` in one round-trip; `redistribute` for reminders; `field/create-many` for bulk placement with percent coords + `fieldMeta`). v1 → existing `/api/v1/documents/*` paths. **Template flow is intentionally still v1** (`/api/v1/templates/{id}/generate-document` with `formValues` keyed by name) — v2 instances accept it via backward compat. Full v2 `/template/use` migration with `prefillFields` by ID needs per-template field-ID capture in admin settings and is deferred. Two per-port v2 settings now wired through `buildDocumensoPayload` + `documensoCreate.meta`: `documenso_signing_order` (PARALLEL/SEQUENTIAL — v2-enforced) and `documenso_redirect_url` (post-sign redirect; both versions honour). `checkDocumensoHealth` returns the resolved `apiVersion` for the admin Test button.
|
||||
- **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `<img>` URLs reference `s3.portnimara.com` directly (will move to `/public` later).
|
||||
- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `<BrandedAuthShell>` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified.
|
||||
- **Sheet vs Drawer doctrine:** `<Sheet side="right">` (`src/components/ui/sheet.tsx`, Radix dialog) is the canonical side-panel for forms and previews on **both** desktop and mobile (`w-3/4 ... sm:max-w-sm` adapts naturally). Vaul `<Drawer>` (`src/components/shared/drawer.tsx`) is reserved for **mobile-only bottom-sheet UX** — currently just the `MoreSheet` nav (`src/components/layout/mobile/more-sheet.tsx`). If you need a side panel of any kind, use Sheet. Don't add new Vaul drawers without a mobile-bottom-sheet justification.
|
||||
- **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `<InlineEditableField>` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `<InlineTagEditor>` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1/<entity>/[id]/tags` endpoint backed by a `set<Entity>Tags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place.
|
||||
- **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `<NotesList entityType="…" />` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape.
|
||||
- **Document folders:** Per-port nestable tree (`document_folders` self-FK on `parent_id`; null parent = root). Documents and files carry a nullable `folder_id` (null = root). Sibling-name uniqueness via `uniq_document_folders_sibling_name` on `(port_id, COALESCE(parent_id, '__root__'), LOWER(name))`. Folder delete is **soft rescue**: `deleteFolderSoftRescue` re-parents every child folder + document + file up to the deleted folder's parent (or to root) inside a transaction, then drops the folder row — never CASCADE. Cycle prevention in `moveFolder` walks the destination's ancestor chain.
|
||||
|
||||
Three system roots (`Clients/`, `Companies/`, `Yachts/`) are auto-created on port init via `ensureSystemRoots`. Per-entity subfolders are created lazily on first auto-deposit / manual upload via `ensureEntityFolder` — concurrent callers race safely via the partial unique index `uniq_document_folders_entity` on `(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL`. The `chk_system_folder_shape` CHECK pins the shape of system rows. Rename/move/delete on `system_managed = true` folders is rejected by `assertNotSystemManaged` (service-level, not DB-level). Entity rename auto-syncs the folder name via `syncEntityFolderName`; archive applies a ` (archived)` suffix via `applyEntityArchivedSuffix`; hard-delete demotes (`system_managed = false`) + appends ` (deleted)` via `demoteSystemFolderOnEntityDelete`.
|
||||
|
||||
Auto-deposit on signing completion: `handleDocumentCompleted` resolves the owner via the Owner-wins chain (`document.clientId ?? .companyId ?? .yachtId ?? interest.clientId ?? interest.yachtId`), ensures the matching entity subfolder, and sets `files.folder_id` + the matching entity FK on the signed file row. Falls back to root when no owner is resolvable. (Note: `interests` table has no `companyId` column, hence the chain's interest fallback omits it.)
|
||||
|
||||
Aggregated projection: `listFilesAggregatedByEntity` / `listInflightWorkflowsAggregatedByEntity` walk the relationship graph from the requested entity (symmetric reach: Client ↔ Company via `company_memberships` filtered to active rows via `isNull(end_date)`, ↔ Yacht via `yachts.current_owner_type/id`) and return results grouped by source (DIRECTLY ATTACHED / FROM COMPANY / FROM YACHT / FROM CLIENT). Each group caps at 20 rows with a total for `Show all (N)`. The files projection LEFT JOINs `documents` on `signed_file_id` to surface `signedFromDocumentId` per row — used by the UI's "view signing details" link. **File-FK snapshot is the source of truth** — historical files stay where they were filed even if the linked entity's relationships change. **Defense-in-depth `port_id` filter at every join** (per recommender precedent) — entry-point check alone is rejected. Completed workflows are hidden from folder views (`listDocuments` excludes `status='completed'` when `folderId` is set); the signed-PDF file surfaces in the Files section with a "view signing details" link to the workflow audit trail (via `GET /api/v1/documents/[id]/signing-details`).
|
||||
|
||||
Hub UI: rebuilt around three render modes — `HubRootView` (no folder), `EntityFolderView` (system-managed entity subfolder, renders Signing-in-progress + Files via the aggregated projection), `FlatFolderListing` (any other folder). Sidebar shows lock markers on system folders and mutes archived entity folders. The signing-status tabs strip (`in_progress` / `awaiting_them` / etc.) was removed; folders are now the primary navigation.
|
||||
|
||||
Permission gating: `documents.view` for read of folders + entity-aggregated listing; `documents.manage_folders` for create / rename / move / delete of user folders (system folders are immutable through the API entirely).
|
||||
|
||||
Deploy: schema migration `0051_documents_hub_split.sql` ships the columns; `pnpm db:backfill:doc-folders` (script `scripts/backfill-document-folders.ts`) runs after the migration and is idempotent (per-port `pg_advisory_xact_lock`).
|
||||
|
||||
- **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware.
|
||||
- **Multi-berth interest model:** `interest_berths` is the source of truth for which berths an interest is linked to; `interests.berth_id` does not exist (dropped in migration 0029). Three role flags: `is_primary` (≤1 row per interest, enforced by partial unique index — surfaces as "the berth for this deal" in templates / forms / list views), `is_specific_interest` (true → berth shows as "Under Offer" on the public map; false → legal/EOI-only link), `is_in_eoi_bundle` (covered by the interest's EOI signature). Read/write through `src/lib/services/interest-berths.service.ts` helpers (`getPrimaryBerth`, `getPrimaryBerthsForInterests`, `upsertInterestBerth`, `setPrimaryBerth`, `removeInterestBerth`); never query `interest_berths` from outside that service.
|
||||
- **Mooring number canonical format:** `^[A-Z]+\d+$` (e.g. `A1`, `B12`, `E18`) — no hyphen, no leading zeros. Stored, displayed, URL-encoded, and rendered in EOIs in this exact form. Phase 0 normalized the entire CRM dataset; the mooring-pattern regex gates the public `/api/public/berths/[mooringNumber]` route before any DB hit.
|
||||
- **Public berths API:** `/api/public/berths` (list) and `/api/public/berths/[mooringNumber]` (single) are the public-facing data feed for the marketing website. Output shape mirrors the legacy NocoDB Berths shape verbatim (`"Mooring Number"`, `"Side Pontoon"`, etc.) — see `src/lib/services/public-berths.ts`. Cache headers: `s-maxage=300, stale-while-revalidate=60`. Status mapping: `"Sold"` (berth.status=sold) > `"Under Offer"` (status=under_offer OR has any active `interest_berths.is_specific_interest=true` link with `interests.outcome IS NULL`) > `"Available"`. The companion `/api/public/health` endpoint returns `{env, appUrl}` so the website refuses to start when its `CRM_PUBLIC_URL` points at a different deployment env.
|
||||
- **Public berths API:** `/api/public/berths` (list) and `/api/public/berths/[mooringNumber]` (single) are the public-facing data feed for the marketing website. Output shape mirrors the legacy NocoDB Berths shape verbatim (`"Mooring Number"`, `"Side Pontoon"`, etc.) — see `src/lib/services/public-berths.ts`. Cache headers: `s-maxage=300, stale-while-revalidate=60`. Status mapping: `"Sold"` (berth.status=sold) > `"Under Offer"` (status=under_offer OR has any active `interest_berths.is_specific_interest=true` link with `interests.outcome IS NULL`) > `"Available"`. The companion `/api/public/health` endpoint is dual-mode: anonymous callers get `{status, timestamp}` (uptime monitors, never 503); requests carrying a timing-safe-matched `X-Intake-Secret` (compared against `WEBSITE_INTAKE_SECRET`) get the full `{status, env, appUrl, timestamp, checks: {db, redis}}` payload and a 503 if any dependency is down. The website uses the authenticated form on startup so it refuses to start when its `CRM_PUBLIC_URL` points at a different deployment env.
|
||||
- **Berth recommender:** Pure SQL ranking (no AI). Lives in `src/lib/services/berth-recommender.service.ts`. Tier ladder A/B/C/D classifies each feasible berth based on its `interest_berths` aggregates. Heat scoring (recency / furthest stage / interest count / EOI count) only fires for tier B (lost/cancelled-only history); per-port admin tunes weights via `system_settings` keys (`heat_weight_*`, `recommender_max_oversize_pct`, `recommender_top_n_default`, `fallthrough_policy`, `fallthrough_cooldown_days`, `tier_ladder_hide_late_stage`). The recommender enforces multi-port isolation both at the entry point (rejects cross-port interest lookups) AND inside the SQL aggregates CTE (defense-in-depth `i.port_id` filter).
|
||||
- **EOI bundle / range formatter:** Multi-berth EOIs render the in-bundle berth set as a compact range string ("A1-A3, B5-B7") via `formatBerthRange()` in `src/lib/templates/berth-range.ts`. Used only inside the Documenso `Berth Range` form field — CRM UI always shows berths as individual chips. The `{{eoi.berthRange}}` token is in `VALID_MERGE_TOKENS`.
|
||||
- **Pluggable storage backend:** Code never imports MinIO/S3 directly. All file I/O goes through `getStorageBackend()` from `src/lib/storage/`. Configured via `system_settings.storage_backend` ('s3' | 'filesystem'). Switching backends is a settings change + `pnpm tsx scripts/migrate-storage.ts` run. **Filesystem backend is single-node only**: refuses to start when `MULTI_NODE_DEPLOYMENT=true`. Multi-node deployments must use the s3-compatible backend.
|
||||
- **Berth rules engine:** Per-port `system_settings` rules in `src/lib/services/berth-rules-engine.ts`. Seven triggers, all wired: `eoi_sent`, `eoi_signed`, `deposit_received` (invoices.ts), `contract_signed` (documents.service.ts), `interest_archived` / `interest_completed` (interests.service.ts), `berth_unlinked` (interest-berths.service.ts). Service callers fire `evaluateRule(trigger, interestId, portId, meta)` via dynamic import to avoid circular deps. Default modes vary (`auto` for state changes, `suggest` for recommendations, `off` for `berth_unlinked`); admins tune via `berth_rules` system_settings key. Webhook auto-advance pairs the rule with `advanceStageIfBehind` so the pipeline stage and berth status move together.
|
||||
- **EOI bundle / range formatter:** Multi-berth EOIs render the in-bundle berth set as a compact range string ("A1-A3, B5-B7") via `formatBerthRange()` in `src/lib/templates/berth-range.ts`. The output populates the existing `Berth Number` Documenso form field (single-berth output is byte-identical to the primary mooring, multi-berth shows the full range). CRM UI always shows berths as individual chips. The `{{eoi.berthRange}}` token is in `VALID_MERGE_TOKENS` for template body copy.
|
||||
- **Pluggable storage backend:** Code never imports MinIO/S3 directly. All file I/O goes through `getStorageBackend()` from `src/lib/storage/`. The `StorageBackend` interface requires `put`, `get`, `head`, `delete`, `listByPrefix`, `presignUpload`, `presignDownload` — any new backend must implement all seven. Configured via `system_settings.storage_backend` ('s3' | 'filesystem'). Switching backends is a settings change + `pnpm tsx scripts/migrate-storage.ts` run (the migrator round-trips every blob in `files`, `berth_pdf_versions`, `brochure_versions`, `gdpr_exports` and verifies SHA-256 — `TABLES_WITH_STORAGE_KEYS` populated in 9a5ba87; was no-op before). MinIO ops are wrapped in a 30s `withTimeout` to prevent TCP-blackhole worker stalls. **Filesystem backend is single-node only**: refuses to start when `MULTI_NODE_DEPLOYMENT=true`. Multi-node deployments must use the s3-compatible backend.
|
||||
- **Per-berth PDFs:** Versioned via `berth_pdf_versions`; `berths.current_pdf_version_id` always points to the latest active version. Storage key is UUID-based per upload (not version-numbered) so concurrent uploads can't collide on blob paths; `pg_advisory_xact_lock` per berth_id serializes the version-number allocation. 3-tier parser: AcroForm → OCR (Tesseract.js with positional heuristics) → optional AI (rep clicks "AI parse" only when OCR confidence is low). Magic-byte (`%PDF-`) check enforced on BOTH the in-server upload path AND the presigned-PUT path (the post-upload service streams the first 5 bytes via the storage backend). Mooring-number mismatch between PDF and target berth surfaces as a service-level `ConflictError` unless the apply call passes `confirmMooringMismatch: true`.
|
||||
- **Brochures:** Per-port; default brochure marked via `is_default` (enforced by partial unique index on `(port_id) WHERE is_default=true AND archived_at IS NULL`). Archived brochures retain version history. Same upload flow as berth PDFs (presign + magic-byte verification on the post-upload register endpoint).
|
||||
- **Send-from accounts (sales send-outs):** Configurable via `system_settings`; defaults to `sales@portnimara.com` for human-touch and `noreply@portnimara.com` for automation. SMTP/IMAP passwords are AES-256-GCM encrypted at rest; the API never returns decrypted secrets — only `*PassIsSet` boolean markers. Send-out audit goes to `document_sends` (separate from `audit_logs` because of volume + binary refs). Body markdown is XSS-safe via `renderEmailBody()` (escape-then-allowlist; tested against the standard XSS vector list). Rate limit: 50 sends/user/hour individual. Pre-send size threshold: files > `email_attach_threshold_mb` ship as a 24h signed-URL link rather than an attachment (avoids the duplicate-send race from async bounces). The download-link fallback HTML-escapes the filename to prevent injection from admin-supplied brochure names. Bounce monitoring requires IMAP credentials in addition to SMTP — without them, the size-rejection banner stays disabled.
|
||||
@@ -118,6 +135,10 @@ When you run a `db:push` or apply a migration via `psql` against a running dev s
|
||||
|
||||
Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full schema. Set `SKIP_ENV_VALIDATION=1` to bypass validation (used in Docker build).
|
||||
|
||||
Required env gotchas:
|
||||
|
||||
- `DOCUMENSO_API_URL` — **bare host only**, never include `/api/v1`. The client appends versioned paths based on `DOCUMENSO_API_VERSION` (`v1` for 1.13.x, `v2` for 2.x). A double-pathed URL returns 404 on every call with no useful diagnostic.
|
||||
|
||||
Optional dev/test-only env vars (not in `.env.example`):
|
||||
|
||||
- `EMAIL_REDIRECT_TO=<address>` — when set, every outbound email is rerouted to this address regardless of the requested recipient and the subject is prefixed with `[redirected from <original>]`. Dev safety net so seeded fake-client emails don't escape; **must be unset in production**.
|
||||
@@ -151,11 +172,11 @@ Domain-specific references:
|
||||
|
||||
- `docs/eoi-documenso-field-mapping.md` — canonical mapping from `EoiContext`
|
||||
paths to the Documenso template's `formValues` keys, with the matching
|
||||
AcroForm field names used by the in-app pathway. **Note:** the multi-
|
||||
berth EOI bundle adds a new `Berth Range` form field populated by
|
||||
`formatBerthRange()` from `src/lib/templates/berth-range.ts` — the live
|
||||
Documenso template needs the field added before multi-berth EOIs render
|
||||
with the compact range string instead of just the primary mooring.
|
||||
AcroForm field names used by the in-app pathway. The `Berth Number`
|
||||
field carries the `formatBerthRange()` output — single-berth EOIs
|
||||
populate it with just the primary mooring (e.g. `A1`), multi-berth
|
||||
EOIs with the compact range (`A1-A3, B5`). No separate `Berth Range`
|
||||
template field is needed (the dedicated field was retired 2026-05-14).
|
||||
- `assets/README.md` — what the in-app EOI source PDF must contain and how
|
||||
to override its path in dev/test.
|
||||
- `docs/berth-recommender-and-pdf-plan.md` — the comprehensive plan for the
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -11,6 +11,11 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# NODE_ENV=production in the builder makes `next build` and any code
|
||||
# branching on isProd deterministic (build-auditor M9). Without this,
|
||||
# CSP and other prod-only paths would compile under whatever NODE_ENV
|
||||
# the host carried in.
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV SKIP_ENV_VALIDATION=1
|
||||
RUN pnpm build
|
||||
@@ -25,6 +30,14 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/dist/server.js ./server-custom.js
|
||||
# Pin socket.io + @socket.io/redis-adapter into the runner — the custom
|
||||
# server (server-custom.js) requires them at runtime, but the Next
|
||||
# tracer has no reason to include them in .next/standalone since no
|
||||
# Next route imports the socket server. (build-auditor C3)
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/socket.io ./node_modules/socket.io
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules/@socket.io ./node_modules/@socket.io
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT:-3000}/api/health || exit 1
|
||||
CMD ["node", "server-custom.js"]
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
# Drop root for the dev runtime — node:alpine ships a `node` user (uid
|
||||
# 1000) for exactly this purpose. Audit caught that running as root in
|
||||
# dev is an unnecessary risk when the bind-mounted source lets a
|
||||
# compromised process write anywhere in the repo.
|
||||
USER node
|
||||
WORKDIR /home/node/app
|
||||
COPY --chown=node:node package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
EXPOSE 3000
|
||||
CMD ["pnpm", "dev"]
|
||||
|
||||
@@ -29,9 +29,30 @@ Documenso template's `formValues` keys — see
|
||||
| `Lease_10` | Checkbox | always `false` (legacy default — Purchase, not Lease) |
|
||||
| `Purchase` | Checkbox | always `true` |
|
||||
|
||||
Form fields stay interactive after generation (not flattened), so the
|
||||
recipient can still tweak values before signing if the in-app pathway is
|
||||
followed by a Documenso send.
|
||||
The fill path **flattens** the AcroForm after writing values, so the
|
||||
recipient can't edit pre-filled values (yacht dimensions, address, berth
|
||||
number) after the fact. Documenso pathway flattens server-side; the
|
||||
in-app pathway brings the artifact to parity.
|
||||
|
||||
### Expected sha256
|
||||
|
||||
The source PDF's sha256 is pinned to guard against silent template swaps
|
||||
(an unreviewed asset swap would change legal output without a code diff):
|
||||
|
||||
```
|
||||
ba495fd88d99ebe4b7f61acbe397fb2f1cd116e1e1f1b217de93106915c7c44b
|
||||
```
|
||||
|
||||
`scripts/check-eoi-template-sha.ts` verifies this at boot of the in-app
|
||||
pathway; the function exposes the expected hash via `EXPECTED_EOI_SHA256`
|
||||
so tests can re-check after a deliberate template revision.
|
||||
|
||||
To intentionally update the template:
|
||||
|
||||
1. Drop the new PDF as `eoi-template.pdf`.
|
||||
2. Run `shasum -a 256 assets/eoi-template.pdf`.
|
||||
3. Update the hash in this README **and** in
|
||||
`src/lib/pdf/fill-eoi-form.ts` (search for `EXPECTED_EOI_SHA256`).
|
||||
|
||||
### Override path
|
||||
|
||||
|
||||
@@ -14,6 +14,19 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
# build-auditor HIGH: bound memory + log rotation so a stuck query or
|
||||
# noisy log doesn't fill the host disk. Postgres respects shared
|
||||
# buffers env via init.sql; the hard limit here is the container
|
||||
# ceiling.
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
@@ -28,6 +41,15 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
@@ -42,7 +64,11 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||
# build-auditor H5: env.PORT is configurable (default 3000), so
|
||||
# template the port into the healthcheck URL. Otherwise overriding
|
||||
# PORT=8080 via .env makes the container healthy-check itself on
|
||||
# the wrong port and enter a restart loop.
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:${PORT:-3000}/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -51,6 +77,15 @@ services:
|
||||
# SIGKILLs the process. The internal hard timeout is 25s.
|
||||
stop_grace_period: 30s
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
@@ -66,6 +101,15 @@ services:
|
||||
# to the queue when worker.ts handles SIGTERM.
|
||||
stop_grace_period: 30s
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
|
||||
# Templatize port so `PORT=…` env overrides don't desync the
|
||||
# healthcheck from the actual listener.
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:${PORT:-3000}/api/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
7522
docs/AUDIT-2026-05-12.md
Normal file
7522
docs/AUDIT-2026-05-12.md
Normal file
File diff suppressed because it is too large
Load Diff
716
docs/AUDIT-FOLLOWUPS.md
Normal file
716
docs/AUDIT-FOLLOWUPS.md
Normal file
@@ -0,0 +1,716 @@
|
||||
# Audit Follow-ups — 2026-05-08 visual audit
|
||||
|
||||
This is the single index for everything from the 2026-05-08 mobile visual
|
||||
audit. Owns: status of each item, file pointers, every open question,
|
||||
and a ready-to-paste prompt for resuming in a fresh session.
|
||||
|
||||
Items are grouped by **wave** (the original triage buckets, kept stable
|
||||
across sessions). Numbering inside each wave matches the original audit
|
||||
message order where possible.
|
||||
|
||||
> **If you only have time for one section, read § "Resuming in a fresh
|
||||
> session" at the bottom.**
|
||||
|
||||
---
|
||||
|
||||
## Quick status snapshot — 2026-05-09 (post-execution)
|
||||
|
||||
| Wave | Topic | Status |
|
||||
| --------- | ------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| 1 | Small confident fixes | ✅ Done |
|
||||
| 2 | Country dropdown unification + cmdk scroll | ✅ Done (country/nationality split still deferred — see Wave 11.E) |
|
||||
| 3 | Berth field overhaul (NocoDB enums) | ✅ Done |
|
||||
| 4 | Currency platform-wide | ✅ Done |
|
||||
| 5 | Configurable enums (admin Vocabularies) | ✅ Admin page + read endpoint shipped; consumer wiring is owed |
|
||||
| 6 | Notes unification (aggregate-on-read) | ✅ Done — yacht / company / residential aggregators + UI |
|
||||
| 7 | Clients / yachts / companies misc | ✅ Status-link flow done; client form expansion still large (Wave 11.A) |
|
||||
| 8 | Expenses revisit | ✅ Done — trip-label combobox (free text + past suggestions) |
|
||||
| 9 | Interests + notifications | ✅ Done |
|
||||
| 10 | Settings polish | ✅ Done — first/last name + collapse notif prefs |
|
||||
| 11.A | Manual client form expansion | 🔴 Not started (large) |
|
||||
| 11.B | Documents folders (unlimited nesting) | 🔴 Not started — needs deep design (sidebar tree + breadcrumb) |
|
||||
| 11.C | Reports system + templates | 🔴 Not started |
|
||||
| 11.D | Receipts inline in expense PDF | 🔴 Not started |
|
||||
| 11.E | Country / Nationality split on Client form | 🔴 Not started |
|
||||
| 11.F | Inquiry triage | 🔴 Deferred |
|
||||
| 11.G | Per-port email branding admin UI | 🔴 Deferred |
|
||||
| **Bonus** | **Public berth feed (website map)** | ✅ Parity fields shipped; cutover deferred (see runbook) |
|
||||
| **Bonus** | **Website cutover runbook** | ✅ Doc shipped (`docs/website-cutover-runbook.md`); execution deferred |
|
||||
| **Bonus** | **Berth Documents tab → Spec + Deal** | ✅ Done |
|
||||
|
||||
Test status: `pnpm exec vitest run` → **1187/1187 pass**.
|
||||
TS check: `pnpm exec tsc --noEmit` → **clean**.
|
||||
Git: 9 commits this session (Waves 4-10 + admin Vocabularies + status-change link + Berth Documents tab split + decisions log).
|
||||
|
||||
---
|
||||
|
||||
## Ground rules / invariants we picked up
|
||||
|
||||
- **Notes unification model**: aggregate-on-read (option 1 from the
|
||||
AskUserQuestion, picked by user). One canonical service per entity
|
||||
unions own-notes + related-entity notes; no replication, no schema
|
||||
migration.
|
||||
- **NocoDB MCP**: connected at `~/.claude.json` under
|
||||
`mcpServers."NocoDB Base - Port Nimara"`. Verified Berths schema +
|
||||
records pull cleanly. The seed-data JSON snapshot
|
||||
(`src/lib/db/seed-data/berths.json`) is also a reasonable fallback
|
||||
if the MCP is unavailable.
|
||||
- **Berth dropdown values** are now sourced from the NocoDB SingleSelect
|
||||
choices verbatim — see `src/lib/constants.ts` (look for
|
||||
`BERTH_*_OPTIONS` / `_TYPES`). Power Capacity and Voltage stay numeric
|
||||
inputs because NocoDB stores them as `Number`. Bow Facing is
|
||||
`SingleLineText` in NocoDB but constrained to the 4 cardinal values
|
||||
in the CRM dropdown for UX.
|
||||
- **Dual-unit fields** auto-cross-fill via `linkedUnit` on
|
||||
`EditableSpec` in `src/components/berths/berth-tabs.tsx`. The user
|
||||
edits the imperial value; the metric column is computed × 0.3048 and
|
||||
patched in the same request.
|
||||
- **Receipts in expense PDF**: user's clarified preference is "PDF
|
||||
images should show inline with the relevant expense" — i.e. images
|
||||
inline; PDF receipts also rendered inline (one page each, via
|
||||
pdfme + `pdf-lib.copyPages`).
|
||||
- **Configurable enums**: the existing pattern is `system_settings`
|
||||
with composite PK `(key, port_id)` and `<SettingsManager>` admin
|
||||
page. Use the same pattern for the new vocabularies.
|
||||
- **Turbopack dev**: `pnpm dev` runs `next dev --turbopack`. Cold
|
||||
compiles ~1s boot, ~3s per route. No webpack hooks in
|
||||
`next.config.ts` so flipping back is one line if needed.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Completed this session
|
||||
|
||||
### Wave 1 — small confident fixes
|
||||
|
||||
1. **Berth list ordering bug** — `\d+$` regex in the Drizzle SQL
|
||||
template was being eaten by JS string literal escape rules
|
||||
(`\d` → `d`). Fixed by switching to `[0-9]+$` POSIX class.
|
||||
File: `src/lib/services/berths.service.ts:69-72`.
|
||||
2. **Dashboard KPI grid removed** — "Total Clients / Active Interests
|
||||
/ Pipeline Value / Occupancy Rate" deleted. The four chart widgets
|
||||
below (pipeline funnel, occupancy timeline, revenue breakdown,
|
||||
lead source) and the activity feed remain.
|
||||
File: `src/components/dashboard/dashboard-shell.tsx`.
|
||||
3. **Per-dock color stripe on mobile berth cards** — was the _status_
|
||||
color, which made every same-dock berth different. Now uses
|
||||
`mooringLetterDot()` so the stripe groups by dock letter; status
|
||||
conveyed by the existing pill below.
|
||||
File: `src/components/berths/berth-card.tsx`.
|
||||
4. **`{Letter} Dock` chip** on the berth detail header replaces the
|
||||
bare "A" / "B" text. Colored by `mooringLetterDot()`.
|
||||
File: `src/components/berths/berth-detail-header.tsx`.
|
||||
5. **cmdk wheel-scroll bug** — Radix Popover swallowed wheel events on
|
||||
the country dropdown for macOS users. Added `onWheel` translator on
|
||||
`CommandList` + `overscroll-contain`. Lights up country pickers in
|
||||
Companies, Residential Clients, Clients, Yachts.
|
||||
File: `src/components/ui/command.tsx`.
|
||||
6. **Mobile "Columns" button hidden** — `ColumnPicker` is now
|
||||
`hidden sm:inline-flex`. Mobile renders cards (no columns to
|
||||
toggle).
|
||||
File: `src/components/shared/column-picker.tsx`.
|
||||
7. **Mobile kanban toggle hidden + auto-fallback** — Interest list
|
||||
hides the table-vs-kanban toggle on small viewports and snaps
|
||||
`viewMode` back to `'table'` if the user's persisted choice was
|
||||
`'board'`.
|
||||
File: `src/components/interests/interest-list.tsx`.
|
||||
8. **Inbox entry removed from mobile More-sheet** — email/IMAP feature
|
||||
is deferred (`sidebar.tsx` calls this out); the More-sheet entry was
|
||||
a dead link.
|
||||
9. **Website Analytics conditional** — desktop sidebar Insights section
|
||||
AND mobile MoreSheet hide the Website Analytics nav when Umami
|
||||
isn't configured for the port. Reuses `useUmamiActive()`.
|
||||
Files: `src/components/layout/sidebar.tsx`,
|
||||
`src/components/layout/mobile/more-sheet.tsx`.
|
||||
10. **"Other" comm-channel UX hint** — when a contact's channel is
|
||||
`'other'`, the inline `Label` field switches its label/placeholder
|
||||
to "Specify" / "e.g. Telegram, Signal".
|
||||
File: `src/components/clients/client-form.tsx:289-302`.
|
||||
11. **End Membership wording** — renamed to "Remove from company" in
|
||||
the company members tab dropdown.
|
||||
File: `src/components/companies/company-members-tab.tsx:249`.
|
||||
12. **Berth area filter → letter dropdown** — was free-text; now a
|
||||
`<Select>` constrained to `A / B / C / D / E`. Label changed to
|
||||
"Dock" to match how the user refers to it.
|
||||
File: `src/components/berths/berth-filters.tsx`.
|
||||
13. **Yacht flag → CountryCombobox** — was a free-text 2-letter input
|
||||
(`placeholder="e.g. MT"`); now uses the same country picker as
|
||||
client / residential.
|
||||
File: `src/components/yachts/yacht-form.tsx`.
|
||||
|
||||
### Wave 2 — country dropdown unification
|
||||
|
||||
1. **cmdk wheel-scroll** — covered in Wave 1 (single shared command).
|
||||
2. **Country → timezone auto-set** in client form: when nationality is
|
||||
picked and timezone empty, the primary IANA zone is pre-filled. Skips
|
||||
when the user already chose a zone explicitly.
|
||||
File: `src/components/clients/client-form.tsx` (look for
|
||||
`primaryTimezoneFor`).
|
||||
3. **Browser-detected timezone fallback** in user settings: timezone
|
||||
pre-populates from `Intl.DateTimeFormat().resolvedOptions().timeZone`
|
||||
on first load (was empty before).
|
||||
File: `src/components/settings/user-settings.tsx`.
|
||||
4. **Country → timezone auto-fill** also fires in user settings when
|
||||
the country changes with no zone set.
|
||||
5. **Dropdown widths match trigger** — `CountryCombobox` and
|
||||
`TimezoneCombobox` popover content set to
|
||||
`w-[var(--radix-popper-anchor-width)]` with sensible `min-w-*`
|
||||
floors so wide triggers get wide popovers.
|
||||
6. **DEFERRED: country/nationality split** on the client form — needs
|
||||
a Drizzle migration (`alter table clients add column country_iso
|
||||
text`) plus a copy-on-migrate of existing `nationality_iso` values.
|
||||
See § Wave 11 / pending — large.
|
||||
|
||||
### Wave 3 — berth field overhaul (NocoDB enums)
|
||||
|
||||
1. **Live NocoDB pull via MCP** — confirmed canonical SingleSelect
|
||||
choices for: Side Pontoon (10 values), Mooring Type (5),
|
||||
Cleat Type (2), Cleat Capacity (2), Bollard Type (2),
|
||||
Bollard Capacity (2), Access (5), Area (A–E). Power Capacity and
|
||||
Voltage are `Number` fields (not enums). Bow Facing is
|
||||
`SingleLineText` (we still use a 4-value dropdown for UX).
|
||||
2. **`BERTH_BOW_FACING_OPTIONS`** added to `src/lib/constants.ts`
|
||||
alongside the existing `BERTH_*_OPTIONS` constants.
|
||||
3. **`toSelectOptions()` helper** added to `src/lib/constants.ts` for
|
||||
mapping readonly tuples → shadcn `<Select>` `{value,label}` objects.
|
||||
4. **All berth dropdown fields → `<Select>`** in both the modal form
|
||||
(`berth-form.tsx`) and the inline-edit detail tabs
|
||||
(`berth-tabs.tsx`). Bow facing / side pontoon / mooring type /
|
||||
access / cleat type / cleat capacity / bollard type / bollard
|
||||
capacity / area / tenure type.
|
||||
5. **Inline-edit `EditableSpec`** in `berth-tabs.tsx` now supports
|
||||
`selectOptions: readonly string[]` to render a `<Select>` variant.
|
||||
6. **Dimensional auto-conversion** — `EditableSpec` gained a
|
||||
`linkedUnit: { field, multiplier }` prop. Saving the imperial value
|
||||
also patches the metric column (× 0.3048). Applied to length, width,
|
||||
draft, nominal boat size, water depth.
|
||||
7. **Nominal boat size editable** — was read-only `<SpecRow>`; now an
|
||||
`<EditableSpec numeric linkedUnit>` so editing ft auto-fills m.
|
||||
8. **Tenure type editable** — was read-only; now an inline-edit Select
|
||||
bound to the validator's `'permanent' | 'fixed_term'` set. Will be
|
||||
replaced by the per-port configurable list once Wave 5 ships.
|
||||
|
||||
### Wave 9 — interests + notifications
|
||||
|
||||
1. **StageLegend popover** — small "Legend" button in the interest
|
||||
list filter row decodes the colored stripes on each card to the
|
||||
pipeline stage name. Stays in sync with `STAGE_DOT` automatically.
|
||||
File: `src/components/interests/stage-legend.tsx`.
|
||||
2. **Mobile kanban hidden** — see Wave 1.
|
||||
3. **Notifications nav 404 fixed** — More-sheet entry pointed at
|
||||
`/notifications` which had no `page.tsx`. Now points at
|
||||
`/notifications/preferences` and is labeled "Notification
|
||||
preferences" — real notifications come via the topbar bell.
|
||||
File: `src/components/layout/mobile/more-sheet.tsx`.
|
||||
|
||||
### Wave 10 — settings polish
|
||||
|
||||
1. **Phone input upgraded** — user settings now uses the existing
|
||||
shared `<PhoneInput>` (country flag dropdown + AsYouType formatter)
|
||||
instead of a plain `<Input type="tel">`. Country state from the
|
||||
page seeds the dropdown.
|
||||
File: `src/components/settings/user-settings.tsx`.
|
||||
2. **Timezone auto-detect** — covered in Wave 2.
|
||||
3. **Dropdown widths match trigger** — covered in Wave 2.
|
||||
|
||||
### Bonus — public berth feed wired to replace NocoDB as source of truth
|
||||
|
||||
Triggered by user prompt "ensure we are properly wired up to replace
|
||||
the NocoDB table as the source of truth for the berth map".
|
||||
|
||||
**State before audit:**
|
||||
|
||||
- API endpoints existed (`/api/public/berths`,
|
||||
`/api/public/berths/[mooringNumber]`) — wiring fine.
|
||||
- `src/lib/services/public-berths.ts` mapped the response shape to
|
||||
NocoDB-verbatim keys.
|
||||
- Tests passed (`tests/unit/services/public-berths.test.ts`).
|
||||
- **Map data was empty: 0 rows in `berth_map_data` against 234 berths
|
||||
total (117 per port).** Without polygons the website map literally
|
||||
has no shapes to render.
|
||||
|
||||
**Action taken:**
|
||||
|
||||
- Ran `pnpm tsx scripts/import-berths-from-nocodb.ts --apply
|
||||
--port-slug port-nimara` (after a clean dry-run). Result:
|
||||
117 berths updated, 117 `berth_map_data` rows inserted.
|
||||
- Spot-checked the public API: `GET /api/public/berths` returns the
|
||||
correct shape with `Map Data` populated, byte-for-byte identical
|
||||
to NocoDB for berth A1 (`path`, `x`, `y`, `transform`, `fontSize`).
|
||||
|
||||
**Field-parity gaps still present** (see Wave Bonus pending below).
|
||||
|
||||
### Misc UI polish
|
||||
|
||||
- **Berth Documents tab explainer** — added a one-paragraph header
|
||||
explaining it's the spec PDF, not deal documents (with a pointer
|
||||
to the Interests tab for prospect-linked docs).
|
||||
File: `src/components/berths/berth-documents-tab.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Pending — medium
|
||||
|
||||
### Wave 4: currency formatting platform-wide
|
||||
|
||||
- Build `<CurrencyInput>` shared component (formatted display, raw
|
||||
number value). Replace raw `<Input type="number">` price spots in:
|
||||
`berth-form.tsx` (price), `expense-form-dialog.tsx` (amount),
|
||||
`invoices.tsx` (totals), client deal amounts on dossier / invoice.
|
||||
- Currency selector dropdown on expense form (NocoDB has no expense
|
||||
currency field, so source from a curated supported-currency list:
|
||||
USD / EUR / GBP / CAD / AUD / CHF / JPY / …). Replace the free-text
|
||||
3-letter input.
|
||||
- Sweep for `${currency} ${amount}` string concatenations and replace
|
||||
with `Intl.NumberFormat`.
|
||||
|
||||
### Wave 5: configurable enum infrastructure
|
||||
|
||||
We have a `system_settings` table with composite PK `(key, port_id)`
|
||||
and an `<SettingsManager>` admin page. Add a "Vocabularies" admin tab
|
||||
that exposes per-port vocabularies. Suggested keys grouped by domain:
|
||||
|
||||
- `interest_temperature_levels` — replaces the hardcoded "HOT" badge.
|
||||
Pill is rendered in `src/components/interests/interest-card.tsx`.
|
||||
- `berth_status_change_reasons` — list shown as quick-pick chips in
|
||||
`<StatusChangeDialog>` (see `berth-detail-header.tsx`). Tied to the
|
||||
prospect-picker concept (see Wave 7 below).
|
||||
- `berth_tenure_types` — replaces the static
|
||||
`'permanent' | 'fixed_term'` validator union. Berths column is
|
||||
`text`, so any value can land at the DB layer.
|
||||
- `expense_categories` — current hardcoded list at
|
||||
`src/lib/constants.ts:EXPENSE_CATEGORIES`.
|
||||
- `document_types` — current hardcoded list at
|
||||
`src/lib/constants.ts:DOCUMENT_TYPES`.
|
||||
- `interest_outcome_statuses` — already exist in schema enum, could
|
||||
be overridable.
|
||||
- `berth_side_pontoon_options` / `berth_cleat_types` /
|
||||
`berth_bollard_types` / `berth_access_options` — currently
|
||||
hardcoded to NocoDB values. Worth making editable once a non-Port-
|
||||
Nimara port appears with different infrastructure.
|
||||
|
||||
**Open question (#1)**: see § Open Questions.
|
||||
|
||||
### Wave 6: notes unification — aggregate-on-read
|
||||
|
||||
User chose option 1 ("aggregate on read") from the brainstorm. The
|
||||
`listForClientAggregated` pattern in `notes.service.ts` (lines
|
||||
130–242) already unions a client's notes + interest notes + owned
|
||||
yacht notes into a single feed with `source` metadata.
|
||||
|
||||
Symmetric extensions to add:
|
||||
|
||||
- `listForYachtAggregated` — yacht own notes + owner client notes
|
||||
- linked interest notes.
|
||||
- `listForCompanyAggregated` — company own notes + owned yacht notes
|
||||
- linked interest notes.
|
||||
- `listForResidentialClientAggregated` — residential client notes
|
||||
- residential interest notes.
|
||||
|
||||
UI:
|
||||
|
||||
- `<NotesList entityType="…">` should render the source-label badge
|
||||
(already implemented for clients — copy the pattern).
|
||||
- Convert single-textarea spots to entry-list pattern: the
|
||||
Companies overview tab has a `notes` textarea (from
|
||||
`companies.notes` text column) AND a Notes tab with the threaded
|
||||
`companyNotes` table. Drop the textarea in favor of the threaded
|
||||
feed only. Same for residential interests.
|
||||
- Note for the schema fix-it list: `companyNotes` is missing
|
||||
`updatedAt`. Service substitutes `createdAt` to keep the read shape
|
||||
uniform — see `notes.service.ts:566`. Fix when convenient.
|
||||
|
||||
### Wave 7: clients / yachts / companies misc
|
||||
|
||||
Done in this session:
|
||||
|
||||
- **Yacht flag** → CountryCombobox (Wave 1).
|
||||
- **End Membership** → "Remove from company" (Wave 1).
|
||||
- **Berth Documents tab** explainer paragraph.
|
||||
|
||||
Pending:
|
||||
|
||||
- **Status change modal — prospect picker**: when user changes berth
|
||||
status to `under_offer` or `sold`, surface an interest/prospect
|
||||
selector below the reason dropdown so the recorded reason can link
|
||||
to a known deal. Tie into `interest_berths` so the link is
|
||||
bidirectional. Depends on Wave 5
|
||||
(`berth_status_change_reasons` vocabulary).
|
||||
- **Documents tagged with company** show up in main `/documents` view
|
||||
with company tag — verify after the documents overhaul (Wave 11.B).
|
||||
|
||||
### Wave 9 follow-up
|
||||
|
||||
- **HOT/WARM/COLD admin-config** — covered by Wave 5
|
||||
(`interest_temperature_levels`).
|
||||
- **Color-codes legend**: shipped as a popover. Optional polish: add
|
||||
a one-time tooltip on first pageload so users discover it.
|
||||
|
||||
### Wave 10 follow-up
|
||||
|
||||
- **Photo upload picker bug**: Playwright captured a `[File chooser]`
|
||||
modal when clicking "Upload photo," so the wiring works in headless
|
||||
Chromium. User reported "doesn't open" on macOS — possibly a focus
|
||||
/ window issue or a content-blocking extension. Need a real-machine
|
||||
repro to diagnose. The hidden `<input type="file" ref={fileInputRef}>`
|
||||
- `fileInputRef.current?.click()` wiring is at
|
||||
`user-settings.tsx:247-258`.
|
||||
- **Display name + first / last name fields** — current schema only
|
||||
has `displayName`. Adding first/last requires a Drizzle migration on
|
||||
`users` or `user_profiles` plus migration of existing data (split
|
||||
on first space). **Open question (#3)**: see § Open Questions.
|
||||
- **Notification preferences placement** — settings vs notifications
|
||||
page. Today notification toggles live on the user-settings page; a
|
||||
dedicated `/notifications/preferences` page also exists. **Open
|
||||
question (#2)**: see § Open Questions.
|
||||
|
||||
### Wave Bonus follow-up — public berth feed field parity
|
||||
|
||||
Map data is now wired. Field gaps the website _might_ consume but we
|
||||
don't expose:
|
||||
|
||||
| NocoDB field | Currently in PublicBerth? | DB has it? | Notes |
|
||||
| ---------------------------- | ------------------------- | ---------------------------------- | ----------------------------------------------------------- |
|
||||
| `Price` | ❌ | ✅ `berths.price` | Pricing-public is a policy decision. **Open question (#4)** |
|
||||
| `Berth Approved` | ❌ | ✅ `berths.berth_approved` | Boolean. Often used to gate "Sold" display |
|
||||
| `Water Depth` | ❌ | ✅ `berths.water_depth` | Sometimes shown in tooltip |
|
||||
| `Width Is Minimum` | ❌ | ✅ `berths.width_is_minimum` | Modifier for "Width" display |
|
||||
| `Water Depth Is Minimum` | ❌ | ✅ `berths.water_depth_is_minimum` | ditto |
|
||||
| `Length (Metric)` | ❌ | ✅ `berths.length_m` | Derivable. Website may consume |
|
||||
| `Width (Metric)` | ❌ | ✅ `berths.width_m` | ditto |
|
||||
| `Draft (Metric)` | ❌ | ✅ `berths.draft_m` | ditto |
|
||||
| `Water Depth (Metric)` | ❌ | ✅ `berths.water_depth_m` | ditto |
|
||||
| `Nominal Boat Size (Metric)` | ❌ | ✅ `berths.nominal_boat_size_m` | ditto |
|
||||
| `CreatedAt` / `UpdatedAt` | ❌ | ✅ timestamps | Cache invalidation hints |
|
||||
| `Interests` (count) | ❌ | derivable | Probably internal-only |
|
||||
| `Interested Parties` (count) | ❌ | derivable | Probably internal-only |
|
||||
|
||||
**Plan once questions are answered:** Add the chosen fields to
|
||||
`PublicBerth` interface in `src/lib/services/public-berths.ts`, the
|
||||
`toPublicBerth()` mapper, and the test fixtures. Trivial; gated only
|
||||
by which fields the website actually uses.
|
||||
|
||||
**Other public-feed concerns to flag**:
|
||||
|
||||
- **No archive flag**: when a berth is retired the public feed will
|
||||
still serve it. Need a `berths.archived_at` column + filter on the
|
||||
route. Plan §4.5 hinted at this. Not urgent.
|
||||
- **CRM-edit drift vs re-imports**: now that reps can edit berth
|
||||
fields (Wave 3), running the import script will skip-edited those
|
||||
rows (`updated_at > last_imported_at`) — that's the right design,
|
||||
but it means once cutover happens the website **must** call CRM
|
||||
`/api/public/berths`, never NocoDB. Coordinate this in the website
|
||||
repo. Useful guard already exists: `/api/public/health`.
|
||||
- **Cache TTL: 5 min**: when a CRM rep marks a berth `sold`, the
|
||||
public website serves "Available" for up to 5 minutes due to
|
||||
`s-maxage=300`. Acceptable for marketing; bump if needed.
|
||||
- **Health endpoint shape**: `/api/public/health` currently returns
|
||||
`{status, timestamp}` but `CLAUDE.md` claims `{env, appUrl}`. One
|
||||
of them is stale; the website may expect either shape. Not blocking
|
||||
but worth aligning.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Pending — large (group-discussion items, Wave 11)
|
||||
|
||||
### A. Manual client form expansion
|
||||
|
||||
User wants "New Client" to support assigning yachts / companies /
|
||||
berths inline (without leaving the form), plus a mini-recommender for
|
||||
picking a berth at create time.
|
||||
|
||||
Scope:
|
||||
|
||||
- "Existing yacht / new yacht" picker.
|
||||
- "Existing company / new company" picker.
|
||||
- "Open an interest with this client" affordance that wires through
|
||||
`interest_berths` and the recommender.
|
||||
- Make sure all standard client modal fields (nationality / source /
|
||||
preferred contact / timezone / tags) remain present.
|
||||
|
||||
Multi-component composition with a lot of cross-entity plumbing.
|
||||
Estimate fully before starting (likely 2–3 days).
|
||||
|
||||
### B. Documents section overhaul
|
||||
|
||||
User wants:
|
||||
|
||||
- Folders (create / delete / nested).
|
||||
- Sort + filter (by date, type, owner).
|
||||
- Wider file-type allowlist (PDF + Office + image is current; expand).
|
||||
- "Documents in progress" filter (contracts / EOIs awaiting signature,
|
||||
things uploaded but unparsed).
|
||||
- Drop or rename the "Signature-based only" pill — confusing copy.
|
||||
- "Expired" tab admin-configurable visibility.
|
||||
- Type-filter dropdown reflects actual types in use (vs the full
|
||||
hardcoded list).
|
||||
|
||||
Refactor of `documents.service.ts` plus a new folders schema
|
||||
(`document_folders` table with port-scoped tree).
|
||||
|
||||
### C. Reports system
|
||||
|
||||
User asked for:
|
||||
|
||||
- Defined report types (Pipeline summary / Revenue / Activity log /
|
||||
Berth occupancy) with documented data shape per type.
|
||||
- Test fixtures for visual QA.
|
||||
- Admin "report templates" with field-level checkboxes letting an
|
||||
admin compose a custom report shape (toggles for each available
|
||||
data field).
|
||||
|
||||
Infra exists (`/api/v1/reports`) but templates are stubs. A proper
|
||||
templating system + per-template field selection adds a few days.
|
||||
|
||||
### D. Receipts inline in expense PDF
|
||||
|
||||
User confirmed: image receipts render inline beneath each expense row,
|
||||
**and** PDF receipts also render inline (one page each). pdfme
|
||||
(already used for EOI) handles both — inline images via the renderer,
|
||||
PDF pages via `pdf-lib.copyPages`. Depends on Wave 8 expense form work.
|
||||
|
||||
### E. Country / Nationality split on Client form
|
||||
|
||||
Client schema has only `nationalityIso`. User wants:
|
||||
|
||||
- New `country_iso` column for _country of residence_ (visible
|
||||
/ primary).
|
||||
- Keep `nationality_iso` as an _optional_ secondary field.
|
||||
|
||||
Requires:
|
||||
|
||||
- Drizzle migration (`alter table clients add column country_iso text`).
|
||||
- Migrate existing data: copy `nationality_iso → country_iso` for
|
||||
every client (current value is more often country of residence in
|
||||
practice).
|
||||
- Update API validators (`clients.ts`).
|
||||
- Update client form UI: primary "Country" CountryCombobox, secondary
|
||||
collapsible "Nationality" row.
|
||||
- Same for residential clients (parallel schema).
|
||||
|
||||
### F. Inquiry triage (legacy spec carryover)
|
||||
|
||||
Per project memory and the "deferred" list at the top of
|
||||
`today-2026-05-08.md`: inquiry triage was explicitly deferred. Tied
|
||||
into the inquiry routing settings (`inquiry_notification_recipients`,
|
||||
`inquiry_contact_email`, `residential_notification_recipients` —
|
||||
already in `system_settings`). Pick this back up when ready to
|
||||
auto-classify website inquiries.
|
||||
|
||||
### G. Per-port email branding
|
||||
|
||||
Also in the deferred list. Templates and settings keys exist
|
||||
(per memory note); the admin UI for editing per-port email branding
|
||||
overrides remains.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Decisions log — 2026-05-09
|
||||
|
||||
All 11 open questions answered. Implementation implications inline.
|
||||
|
||||
1. **Vocabularies admin layout (Wave 5)** → **New `/admin/vocabularies`
|
||||
page, grouped by domain, admin-only.** User considered exposing to
|
||||
non-admins (since reps use them daily) but settled on admin-only as
|
||||
the safer default for now. Implementation: new top-level admin
|
||||
route + page, reuse `system_settings` `(key, port_id)` composite
|
||||
PK. Each vocabulary key gets its own card section (interest temps,
|
||||
status-change reasons, tenure types, expense categories, document
|
||||
types, etc.).
|
||||
2. **Notification preferences placement (Wave 10)** → **Collapse to
|
||||
user-settings only.** Keep `/notifications/preferences` as a
|
||||
server-side redirect to the user-settings notifications panel for
|
||||
back-compat links.
|
||||
3. **Display name vs first/last (Wave 10)** → **Add `first_name` and
|
||||
`last_name` columns.** Don't worry about migrations during dev (we
|
||||
can iterate freely), but write the migration carefully so it
|
||||
applies cleanly when we eventually deploy. Keep `display_name` as
|
||||
a derived/optional override.
|
||||
4. **Public-feed `Price` exposure (Bonus)** → **No — keep Price
|
||||
internal.** Don't add to PublicBerth payload.
|
||||
5. **Public-feed remaining fields (Bonus)** → **Yes, add all.** Add
|
||||
Berth Approved, Water Depth, Width Is Minimum, Water Depth Is
|
||||
Minimum, all four metric variants, plus CreatedAt/UpdatedAt to
|
||||
PublicBerth + mapper + tests. User noted "not sure if we'll use
|
||||
all of them but best to keep them in" — verbatim NocoDB parity.
|
||||
6. **Website cutover plan (Bonus)** → **Double-write transition
|
||||
window.** Keep both feeds live, write to both for the transition
|
||||
period, then decommission NocoDB. Coordinate with website repo
|
||||
(`CRM_PUBLIC_URL`).
|
||||
7. **Status-change modal → prospect link (Wave 7)** → **Force
|
||||
interest pick + auto-create primary `interest_berths` row.**
|
||||
When status moves to `under_offer` or `sold`, the modal surfaces
|
||||
an interest selector below the reason dropdown. Picking an
|
||||
interest creates an `interest_berths` row with `is_primary=true`
|
||||
if one doesn't already exist for that pair. Depends on Wave 5
|
||||
`berth_status_change_reasons` vocabulary.
|
||||
8. **Trip label on expenses (Wave 8)** → **Combobox: free-text on
|
||||
first entry, dropdown of existing labels on subsequent entries.**
|
||||
No new entity. Source the dropdown from
|
||||
`SELECT DISTINCT trip_label FROM expenses WHERE port_id=?`
|
||||
ordered by recency. UI is a `<Combobox>` with "Create
|
||||
'<typed value>'" affordance.
|
||||
9. **Documents folders (Wave 11.B)** → **Per-port, unlimited
|
||||
nesting depth — but render carefully.** User wants flexibility;
|
||||
we owe a UI design that handles deep trees gracefully (likely
|
||||
collapsed-by-default with a breadcrumb header inside the folder
|
||||
view rather than always-expanded sidebar tree).
|
||||
10. **Berth Documents tab (Wave 1 carryover)** → **Split into two
|
||||
tabs: "Spec" (versioned spec PDF) and "Deal Documents"
|
||||
(aggregated EOIs/contracts from interests on this berth).**
|
||||
Permission scoping: deal docs only show entries the viewer can
|
||||
already see via the linked interest.
|
||||
11. **Mooring type re-import** → ✅ **Verified.** All 117 records
|
||||
have `mooring_type` populated post-import (e.g. "Side Pier / Med
|
||||
Mooring"). No action needed.
|
||||
|
||||
---
|
||||
|
||||
## File-pointer cheat sheet
|
||||
|
||||
### Berth-related
|
||||
|
||||
| Concern | File(s) |
|
||||
| ---------------------------------- | ---------------------------------------------------- |
|
||||
| Canonical berth enums | `src/lib/constants.ts` (search `BERTH_`) |
|
||||
| Berth list ordering SQL | `src/lib/services/berths.service.ts:69-72` |
|
||||
| Berth detail inline edit | `src/components/berths/berth-tabs.tsx` |
|
||||
| Berth modal form | `src/components/berths/berth-form.tsx` |
|
||||
| Berth area filter | `src/components/berths/berth-filters.tsx` |
|
||||
| Berth detail header / status modal | `src/components/berths/berth-detail-header.tsx:90` |
|
||||
| Berth Documents tab | `src/components/berths/berth-documents-tab.tsx` |
|
||||
| Berth list query + sort | `src/lib/services/berths.service.ts:25-140` |
|
||||
| Berth import script | `scripts/import-berths-from-nocodb.ts` |
|
||||
| Berth import service / parsers | `src/lib/services/berth-import.ts` |
|
||||
| Public berth API route | `src/app/api/public/berths/route.ts` |
|
||||
| Public berth single route | `src/app/api/public/berths/[mooringNumber]/route.ts` |
|
||||
| Public berth mapper | `src/lib/services/public-berths.ts` |
|
||||
| Public berth tests | `tests/unit/services/public-berths.test.ts` |
|
||||
| Berth seed snapshot | `src/lib/db/seed-data/berths.json` |
|
||||
| Berth schema | `src/lib/db/schema/berths.ts` (incl. `berthMapData`) |
|
||||
|
||||
### Other domains
|
||||
|
||||
| Concern | File(s) |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Interest stage colors / legend | `src/components/interests/stage-legend.tsx` + `src/lib/constants.ts:STAGE_DOT` |
|
||||
| Mobile kanban toggle / fallback | `src/components/interests/interest-list.tsx` |
|
||||
| Country / timezone autoset | `src/components/clients/client-form.tsx` + `src/components/settings/user-settings.tsx` |
|
||||
| Phone input | `src/components/shared/phone-input.tsx` |
|
||||
| Country combobox + scroll patch | `src/components/shared/country-combobox.tsx` + `src/components/ui/command.tsx` |
|
||||
| Sidebar Umami gate | `src/components/layout/sidebar.tsx` (search `umamiRequired`) |
|
||||
| Mobile More-sheet | `src/components/layout/mobile/more-sheet.tsx` |
|
||||
| Notes service (aggregate-on-read) | `src/lib/services/notes.service.ts:130-242` |
|
||||
| Notes UI | `src/components/shared/notes-list.tsx` |
|
||||
| Settings manager (admin) | `src/components/admin/settings/settings-manager.tsx` |
|
||||
| User settings page | `src/components/settings/user-settings.tsx` |
|
||||
| Status change dialog | `src/components/berths/berth-detail-header.tsx:90` |
|
||||
| Companies members tab | `src/components/companies/company-members-tab.tsx` |
|
||||
| Yacht form | `src/components/yachts/yacht-form.tsx` |
|
||||
| Client form | `src/components/clients/client-form.tsx` |
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Concern | File(s) |
|
||||
| ------------------------------------------- | --------------------------------------------- |
|
||||
| Drizzle config / migrations | `drizzle.config.ts`, `src/lib/db/migrations/` |
|
||||
| `system_settings` table | `src/lib/db/schema/system.ts:128-147` |
|
||||
| Permissions / `withAuth` / `withPermission` | `src/lib/api/helpers.ts` |
|
||||
| Body parsing (always use `parseBody`) | `src/lib/api/route-helpers.ts` |
|
||||
| Storage backend abstraction | `src/lib/storage/` |
|
||||
| Logger (pino) | `src/lib/logger.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Resuming in a fresh session
|
||||
|
||||
When you open a new chat, paste this **prompt** to pick up where this
|
||||
session ended:
|
||||
|
||||
```
|
||||
I'm resuming the 2026-05-08 visual audit. Read
|
||||
docs/AUDIT-FOLLOWUPS.md first — it has every completed item, every
|
||||
pending item, and every open question. Then:
|
||||
|
||||
1. Skim the "Quick status snapshot" table at the top so you know
|
||||
what's done.
|
||||
2. Read the "Open questions for the user" list and ask me question
|
||||
#N where N is whichever I'll answer first this turn.
|
||||
3. Wait for my answers; don't start implementing until I confirm.
|
||||
|
||||
Key invariants:
|
||||
- Notes unification model: aggregate-on-read.
|
||||
- Berth dropdown values: NocoDB SingleSelect canon, sourced from
|
||||
src/lib/constants.ts (BERTH_*_OPTIONS / _TYPES).
|
||||
- Power Capacity & Voltage stay numeric inputs; Bow Facing is a
|
||||
constrained 4-value dropdown despite being SingleLineText in
|
||||
NocoDB.
|
||||
- linkedUnit on EditableSpec auto-fills the metric column on save.
|
||||
- system_settings (key, port_id) is the configuration pattern.
|
||||
- NocoDB MCP is connected via ~/.claude.json — Berths schema +
|
||||
records can be pulled live.
|
||||
- Public berth feed (/api/public/berths) now serves Map Data; 117
|
||||
berth_map_data rows backfilled in this session.
|
||||
- Tests: 1185/1185 passing; tsc clean.
|
||||
|
||||
The git working tree has 23 modified files + 2 new (no commits yet).
|
||||
Don't commit anything until I say so.
|
||||
```
|
||||
|
||||
### Resume commands (cheat sheet)
|
||||
|
||||
```bash
|
||||
cd /Users/matt/Repos/new-pn-crm
|
||||
pnpm dev # Turbopack dev (~1s boot)
|
||||
|
||||
# Tests
|
||||
pnpm exec vitest run # Unit + integration (~7s)
|
||||
pnpm exec tsc --noEmit # Type check
|
||||
pnpm exec playwright test --project=smoke # Smoke (~10min)
|
||||
|
||||
# NocoDB import (for new berth pulls)
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run --port-slug port-nimara
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara
|
||||
|
||||
# DB inspect
|
||||
PGPASSWORD=changeme psql -h localhost -p 5434 -U crm -d port_nimara_crm
|
||||
|
||||
# Public-feed sanity check
|
||||
curl -s http://localhost:3000/api/public/berths | jq '.pageInfo'
|
||||
curl -s http://localhost:3000/api/public/berths/A1 | jq '.'
|
||||
```
|
||||
|
||||
### Verification checklist before committing this session's work
|
||||
|
||||
- [ ] `pnpm exec vitest run` — 1185/1185 pass.
|
||||
- [ ] `pnpm exec tsc --noEmit` — clean.
|
||||
- [ ] `pnpm exec playwright test --project=smoke` — passes.
|
||||
- [ ] Manual: open `/port-nimara/berths`, confirm sort is A1, A2,
|
||||
A3 … A10, A11 (not lex order).
|
||||
- [ ] Manual: open a berth detail page, confirm the dock chip reads
|
||||
e.g. "A Dock", and the Bow Facing / Side Pontoon / Cleat fields
|
||||
render as `<Select>` not `<Input>`.
|
||||
- [ ] Manual: pick a country in the user-settings page and confirm
|
||||
timezone auto-fills if empty; also confirm the country dropdown
|
||||
scrolls with mousewheel on macOS.
|
||||
- [ ] Manual: check the mobile More-sheet has no "Inbox" entry, and
|
||||
"Notification preferences" deep-links to the correct page.
|
||||
- [ ] Manual: open `/api/public/berths` in the browser and search for
|
||||
`Map Data` in the response — every row should have it.
|
||||
|
||||
---
|
||||
|
||||
## Misc tracking notes
|
||||
|
||||
- **Backups**: `~/.claude.json.bak.<timestamp>` exists from when the
|
||||
NocoDB MCP was added. Delete after a session or two if everything's
|
||||
stable.
|
||||
- **Turbopack flip**: `next.config.ts` has no custom `webpack()` hook
|
||||
so reverting `pnpm dev` to plain `next dev` is one line if needed.
|
||||
Default is now `--turbopack`.
|
||||
- **Database integrity follow-ups** (separate audit, dated 20:42):
|
||||
11 findings (5 critical / 6 important). Logged in
|
||||
`.remember/today-2026-05-08.md`. Cross-cuts the work here in two
|
||||
spots: (1) `upsertInterestBerth` race could affect the berth
|
||||
recommender once it's wired into the manual client form (Wave 11.A);
|
||||
(2) `system_settings` `ON DELETE NO ACTION` will need addressing
|
||||
before any port-deletion flow ships.
|
||||
212
docs/AUDIT-PARKED-QUESTIONS.md
Normal file
212
docs/AUDIT-PARKED-QUESTIONS.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Parked questions — needs product / business / design decision
|
||||
|
||||
Items from the 33-agent audit that I deliberately did NOT fix automatically, because they need a call from you (or someone in product / legal / design) before code can be written. Each entry: the finding, why it's parked, and the proposed options.
|
||||
|
||||
Numbered to match the tiers in `AUDIT-TRIAGE.md`.
|
||||
|
||||
---
|
||||
|
||||
## P-0.1 — Migration runner: which approach?
|
||||
|
||||
**Finding.** `pnpm db:push` silently skips `CREATE INDEX CONCURRENTLY` and `NULLS NOT DISTINCT` constraints, plus the `berths.current_pdf_version_id` circular FK. Production is running without 6 composite indexes from migration 0052.
|
||||
|
||||
**Why parked.** Three viable approaches:
|
||||
|
||||
- **Drizzle's built-in `migrate()`** — simplest, but doesn't support `CREATE INDEX CONCURRENTLY` (the kit wraps every migration in a transaction, and CONCURRENTLY can't run inside one).
|
||||
- **A custom tsx script** that reads `0001*.sql` … `0056*.sql` in order, splits on `--> statement-breakpoint`, runs each statement, special-cases CONCURRENTLY by running it outside a tx, tracks state in a `__drizzle_migrations` table.
|
||||
- **Adopt a third-party migrator** (graphile-migrate, dbmate, pg-migrate). Best ergonomics, biggest dependency to take on.
|
||||
|
||||
**Question.** Which one do you want? If you don't know, my recommendation is **custom tsx script** — keeps the dependency surface tight and matches the rest of the platform's "write a script for it" pattern.
|
||||
|
||||
---
|
||||
|
||||
## P-0.4 — Resolve-identifier hit-path still echoes real email
|
||||
|
||||
**Finding.** Rate-limit + synthetic-miss are in, but on a hit the endpoint still returns the user's canonical email. A guessable-username window still leaks.
|
||||
|
||||
**Why parked.** The real fix is to delete the endpoint entirely and have the login form POST `{identifier, password}` to a server-side proxy that resolves + calls Better Auth in one round-trip, never returning the email. That's a noticeable refactor to the login page and possibly the portal-login page too.
|
||||
|
||||
**Question.** Do I do the proxy refactor (~30 min) or keep the current rate-limited shape and accept the residual leak?
|
||||
|
||||
---
|
||||
|
||||
## P-0.5 — Orphan-blob windows in 9+ services
|
||||
|
||||
**Finding.** Every `storage.put` runs outside the `db.insert(files)` tx in `documents`, `brochures`, `invoices`, `gdpr-export`, `backup`, `berth-pdf`, `external-eoi`, `document-templates`, `reports`. A comment in one site claims a "reaper handles it" — no reaper exists.
|
||||
|
||||
**Why parked.** Two valid patterns, both meaningful work:
|
||||
|
||||
- **Compensating delete** — wrap each `storage.put` in a try/catch and `storage.delete()` on tx failure.
|
||||
- **Saga / 2-phase** — write to a `pending_blobs` table inside the tx, async-confirm after the tx commits, async-reaper for orphans.
|
||||
|
||||
Compensating-delete is faster to ship but doesn't catch process-crash gaps. Saga is more robust but is a bigger change.
|
||||
|
||||
**Question.** Which pattern? Recommendation: compensating-delete for now + a simple `cron` reaper that lists all blobs not referenced by any `files`/`berth_pdf_versions`/etc. row and deletes them after a grace period.
|
||||
|
||||
---
|
||||
|
||||
## P-1.1 — GDPR Article-15 export completeness
|
||||
|
||||
**Finding.** `gdpr-bundle-builder.ts` is missing ~10 PII-bearing tables — portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions.
|
||||
|
||||
**Why parked.** Each table needs (a) FK verification that "row belongs to this client" is unambiguous, (b) whether port-isolation must be enforced, (c) whether to include verbatim PII (email bodies, message contents) or redacted versions. This is a careful per-table audit that benefits from someone who knows the data model intimately.
|
||||
|
||||
**Question.** Want me to do a per-table table-by-table follow-up (estimated ~45 min) once you confirm the redaction policy? Or have legal review the scope first?
|
||||
|
||||
---
|
||||
|
||||
## P-1.2 — Right-to-be-forgotten doesn't actually erase
|
||||
|
||||
**Finding.** `client-hard-delete.service.ts` nullifies FKs but verbatim PII survives in `email_messages.body_html`, `files`, `document_sends.recipient_email`.
|
||||
|
||||
**Why parked.** **This is a legal decision, not a coding one.** Some jurisdictions (notably France) require true erasure even of email-body content; others accept anonymization. The fix is mechanical once you decide the policy: a `wipeClientPii(clientId)` helper that overwrites every PII column with a tombstone string. But the scope (which fields, which timeline, which audit trail) is yours / legal's.
|
||||
|
||||
**Question.** What's the erasure policy? Anonymize (preserve audit trail) or truly delete (loses business records)?
|
||||
|
||||
---
|
||||
|
||||
## P-1.3 — Activation / reset tokens travel in `?token=` query strings
|
||||
|
||||
**Finding.** Browser history, proxy logs, Referer header all see the token.
|
||||
|
||||
**Why parked.** Fix is a redesign of the URL scheme — switch to `#token=…` (fragment) or POST-on-load. Both work but require coordinated changes to email templates + the landing pages + Better Auth integration. Estimated 30-45 min.
|
||||
|
||||
**Question.** Want me to do the fragment-based redesign?
|
||||
|
||||
---
|
||||
|
||||
## P-2.1 — `pipelineValueUsd` sums mixed currencies as USD
|
||||
|
||||
**Finding.** The dashboard tile labelled "Pipeline Value" sums berth prices in their native currencies but renders the total as USD.
|
||||
|
||||
**Why parked.** Three valid UX options:
|
||||
|
||||
- **Convert at display time** — fetch each price, convert to port-default-currency via `currency.service`, sum the converted values. Today's rates introduce drift relative to historical reports.
|
||||
- **Show as port-default-currency totalled** — the dashboard tile labels it as the port's own currency; honest about ambiguity.
|
||||
- **Show "mixed (X USD, Y EUR, Z GBP)"** — explicit, prevents misreading, but uglier.
|
||||
|
||||
**Question.** Which display do you want? My recommendation is **option 2** (show port-default-currency, convert at display) — it's the least visually noisy and lines up with what most CRMs do.
|
||||
|
||||
---
|
||||
|
||||
## P-2.5 — "Active interest" means 4 different things
|
||||
|
||||
**Finding.** Dashboard tiles use `outcome IS NULL OR 'won'`, kanban uses `archivedAt NULL` only (lost cards visible), hot deals uses `outcome IS NULL` (excludes won), PDF reports use `archivedAt NULL` only.
|
||||
|
||||
**Why parked.** Need a canonical definition. Recommendation: **active = `archivedAt IS NULL AND outcome IS NULL`** (not yet won, not yet lost, not yet cancelled, not yet archived). But that demotes won deals out of "active" everywhere — affects the kanban "won" column and the dashboard "active deals" tile.
|
||||
|
||||
**Question.** Confirm the canonical definition, then I extract an `activeInterestsWhere(portId)` helper and route every site through it.
|
||||
|
||||
---
|
||||
|
||||
## P-2.6 — Occupancy rate: berths.status vs berth_reservations
|
||||
|
||||
**Finding.** KPI tile + PDF use `berths.status` ("occupied"/"available"/etc). Analytics timeline uses `berth_reservations`. Same dashboard, two different numbers.
|
||||
|
||||
**Why parked.** Need to know which is the source of truth. Probably `berth_reservations` (richer; supports timeline), but switching the KPI tile changes the displayed number for every port.
|
||||
|
||||
**Question.** Which is canonical? I'll switch the other to match.
|
||||
|
||||
---
|
||||
|
||||
## P-2.7 — Revenue PDF unweighted vs dashboard weighted
|
||||
|
||||
**Finding.** Revenue PDF shows gross berth prices per stage. Dashboard revenue-forecast tile multiplies by `pipeline_weights`. They will never reconcile.
|
||||
|
||||
**Why parked.** Need PM call on what "Revenue" means in each context. The PDF is probably a board / investor doc and should match dashboard, but maybe they want both.
|
||||
|
||||
**Question.** Make the PDF match the dashboard (weighted)? Or leave divergent and label them differently?
|
||||
|
||||
---
|
||||
|
||||
## P-3.1 — "Interest" / "lead" / "prospect" / "deal" used interchangeably
|
||||
|
||||
**Finding.** All four nouns appear in client-facing UI. `berth-detail-header.tsx` literally parenthesises one as a synonym ("the prospect (interest)"). `berth-tabs.tsx` has a "Deal Documents" tab + `/deal-documents` URL path.
|
||||
|
||||
**Why parked.** Need a canonical noun. Without one I'd be guessing; with one I can do a codemod across the platform.
|
||||
|
||||
**Question.** Which one is canonical? Recommendation: **interest** (matches schema + URL + most code). Then everything else becomes a deprecated alias.
|
||||
|
||||
---
|
||||
|
||||
## P-3.3 — 16 `window.confirm()` sites for destructive flows
|
||||
|
||||
**Finding.** Cancel signing envelope, delete files, archive interest/company/yacht, etc. all use the native browser dialog.
|
||||
|
||||
**Why parked.** Mechanical fix once you confirm: each site swaps `window.confirm()` for `<AlertDialog>` from `@/components/ui/alert-dialog`. But there are 16 of them; ~5 min each.
|
||||
|
||||
**Question.** OK to do the sweep automatically with the same dialog copy + visual treatment? Or do you want bespoke copy per surface?
|
||||
|
||||
---
|
||||
|
||||
## P-3.4 — Signing-status labels diverge across 5 surfaces
|
||||
|
||||
**Finding.** Hub list, interest-tab, SigningProgress, notification-digest, realtime-toast all use different strings for the same document state.
|
||||
|
||||
**Why parked.** Need one canonical mapping. I drafted `PORTAL_SIGNING_LABELS` for the portal but the CRM side has different needs (more granular for reps).
|
||||
|
||||
**Question.** Want me to extract a shared `signingStatusLabel()` and route every site through it? If yes, I need a confirmed label map.
|
||||
|
||||
---
|
||||
|
||||
## P-3.5 — 6× "Save" button variants
|
||||
|
||||
**Finding.** "Save", "Save Changes", "Save changes", "Update", "Apply" — plus "Saving..." vs "Saving…".
|
||||
|
||||
**Why parked.** Mechanical sweep once you confirm the canonical text. Recommendation: **"Save changes"** for edits, **"Create X"** for new entities, **"Saving…"** (Unicode ellipsis) for the loading state. Trivial codemod but it touches 30+ files.
|
||||
|
||||
**Question.** OK to do the sweep with that policy?
|
||||
|
||||
---
|
||||
|
||||
## P-3.6 — Live Documenso template missing `Berth Range` field
|
||||
|
||||
**Finding.** The CRM sends a `Berth Range` form value through `buildDocumensoPayload`, but the live template at Documenso doesn't have that field — Documenso silently drops unknown formValues. Every multi-berth EOI ships with only the primary mooring.
|
||||
|
||||
**Why parked.** **Not code — Documenso admin action.** Someone needs to log into the Documenso instance and add a `Berth Range` text field to template id 8. The CRM is ready.
|
||||
|
||||
**Question.** Who has Documenso admin access? Can they add the field?
|
||||
|
||||
---
|
||||
|
||||
## P-4.5 — "Convert to client" prefill qs params unused
|
||||
|
||||
**Finding.** The inquiry-inbox triage flow writes `prefill_name/email/phone/inquiry_id/source` query-string params. No consumer reads them. The flow eagerly flips the inquiry to "converted" then drops the operator on a blank form, losing the inquiry_id linkage forever.
|
||||
|
||||
**Why parked.** Fix is a wire-up: the create-client form's `useEffect` reads searchParams and hydrates initial values. But it also has to push the `inquiry_id` into the resulting client's `metadata` so the linkage survives. Not difficult; needs ~30 min and design review on what the linkage looks like.
|
||||
|
||||
**Question.** Want me to wire it up with the inquiry_id stored on `clients.metadata.source_inquiry_id`?
|
||||
|
||||
---
|
||||
|
||||
## P-5.1 — `handleDocumentCompleted` TOCTTOU
|
||||
|
||||
**Finding.** Two concurrent retries can both pass the idempotency gate, both write the signed PDF blob, both insert duplicate files rows. Webhook + poll-worker race specifically.
|
||||
|
||||
**Why parked.** Fix is a `SELECT … FOR UPDATE` on the documents row inside the handler. Mechanical but invasive — touches the hottest path in the signing flow. I want to test before shipping, and that needs a real Documenso webhook replay.
|
||||
|
||||
**Question.** OK to ship the FOR UPDATE without a replay test, relying on existing vitest? Or hold until you can replay?
|
||||
|
||||
---
|
||||
|
||||
## P-5.2 — Zero BullMQ `jobId` usage repo-wide
|
||||
|
||||
**Finding.** Every `queue.add` is unkeyed; any double-fire creates a duplicate job. The audit found this is the most pervasive concurrency hazard in the codebase.
|
||||
|
||||
**Why parked.** Fix is mechanical: pass a deterministic `jobId` to every `queue.add` call. But "deterministic" varies by surface (webhook deliveries should use the delivery row id, notifications should use a hash of the dedupeKey, etc.). ~20 sites to touch.
|
||||
|
||||
**Question.** Want me to do the sweep with per-surface jobId conventions, or batch by surface (webhooks first, then notifications, etc.)?
|
||||
|
||||
---
|
||||
|
||||
## P-6.2 — Recharts in initial bundle (~80-150KB)
|
||||
|
||||
**Finding.** Every dashboard chart imports recharts statically via `widget-registry.tsx`. Initial-page-load bundle includes recharts even if the user has all chart widgets disabled.
|
||||
|
||||
**Why parked.** Fix is straightforward (dynamic import each chart widget), but the widget-registry is hot-pathed by the dashboard renderer and by the widget picker UI. Touching it has surface area.
|
||||
|
||||
**Question.** OK to ship a `next/dynamic` lazy-import for each chart widget? Adds a loading skeleton flash but kills the bundle bloat.
|
||||
|
||||
---
|
||||
|
||||
_Everything in `AUDIT-TRIAGE.md` Tier 8 is already shipped. Everything not listed in this file has been fixed without parking — see the commit log on `feat/documents-folders`._
|
||||
153
docs/AUDIT-TRIAGE.md
Normal file
153
docs/AUDIT-TRIAGE.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Port Nimara CRM — Audit Triage (importance-grouped)
|
||||
|
||||
Companion to `AUDIT-2026-05-12.md`. Every line below is a real finding from the 33-agent audit, regrouped strictly by **impact × likelihood of biting you**, not by which domain found it. Tackle tiers top-down.
|
||||
|
||||
---
|
||||
|
||||
## Tier 0 — Stop-ship: do these in the next session
|
||||
|
||||
Anything here is a foot-gun that's actively armed in production right now.
|
||||
|
||||
| # | What | Where | Why now |
|
||||
| --- | ------------------------------------------------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0.1 | Build a real `db:migrate` runner | new tsx script | `pnpm db:push` silently skips `CREATE INDEX CONCURRENTLY` (6 indexes in 0052 never created) and skips 2 structural constraints. Every other "migration X exists" claim is unverifiable until this is fixed. |
|
||||
| 0.2 | `EMAIL_REDIRECT_TO` prod refusal in `src/lib/env.ts` | env zod refine | One stray env value silently funnels every outbound (invites, EOI, portal magic links, contracts) to a single inbox. Only signal today is `logger.debug`. |
|
||||
| 0.3 | Admin self-target audit-log retention + alerting | audit_logs metadata + retention cron | `audit_logs.metadata` not in `maskSensitiveFields`, no retention cron. PII grows unbounded; rotated-admin compromise is invisible. |
|
||||
| 0.4 | Resolve-identifier hit-path still echoes the real email | `/api/auth/resolve-identifier/route.ts` | Rate-limit is in (just shipped), but on a hit we still return the canonical email. Replace with a server-side signIn proxy that takes `{identifier, password}` and never returns the email at all. |
|
||||
| 0.5 | Orphan-blob windows in 9+ services | `documents`, `brochures`, `invoices`, `gdpr-export`, `backup`, `berth-pdf`… | Every `storage.put` runs outside the `db.insert(files)` tx. "Reaper handles it" comment is wrong — no reaper exists. Months of operation = hundreds of orphans. |
|
||||
| 0.6 | `backup_jobs.storage_path` missing from `TABLES_WITH_STORAGE_KEYS` | `src/lib/storage/migrate.ts:55-60` | Flip the storage backend → silently orphans every pg_dump. Last-resort recovery path goes dark. |
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — Compliance / legal liability
|
||||
|
||||
Anything here puts the company in a regulator finding or a court case.
|
||||
|
||||
| # | What | Where |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 1.1 | GDPR Article-15 export bundle is incomplete | `gdpr-bundle-builder.ts` — missing portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions |
|
||||
| 1.2 | Right-to-be-forgotten doesn't actually erase | `client-hard-delete.service.ts` — verbatim PII survives in `email_messages.body_html`, `files`, `document_sends.recipient_email` |
|
||||
| 1.3 | Activation/reset tokens travel in `?token=` URL query strings | portal-auth flow — leaks to browser history, proxy logs, Referer headers |
|
||||
| 1.4 | `error_events.request_body_excerpt` redacts password/token but not email/phone/name/dob/address | error-classifier sanitizer |
|
||||
| 1.5 | `audit_logs` no retention cron + IP captured on routine events | `lib/audit.ts` — lawful-basis-questionable |
|
||||
| 1.6 | S3 backend ships without `ServerSideEncryption` header | `S3Backend.put` — signed contracts, GDPR exports, pg_dumps cleartext at rest unless bucket default is set |
|
||||
| 1.7 | `audit_logs.metadata` carries raw PII (full emails) at portal-auth, crm-invite, hard-delete, email-accounts service sites | `maskSensitiveFields` skips metadata |
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Money/numbers correctness
|
||||
|
||||
Anything where the dashboard or a PDF lies to the user about money.
|
||||
|
||||
| # | What | Where |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| 2.1 | `pipelineValueUsd` sums mixed currencies as USD | `dashboard.service.ts:39-51`, KPI cards, pipeline-value tile, revenue forecast |
|
||||
| 2.2 | Revenue PDF "TOTAL COMPLETED REVENUE" includes lost + cancelled | `report-generators.ts:126-140` — no outcome filter |
|
||||
| 2.3 | Pipeline PDF crashes because `stageCounts` is missing `.groupBy()` | `report-generators.ts` |
|
||||
| 2.4 | Hot-deals widget rank ladder uses wrong stage names (`'in_comms'`, `'deposit_10'`) | `dashboard.service.ts:198-208`, `hot-deals-card.tsx:26-36` |
|
||||
| 2.5 | "Active interest" means **4 different things** across dashboard / kanban / hot deals / PDFs | extract `activeInterestsWhere(portId)` helper |
|
||||
| 2.6 | Occupancy rate: KPI uses `berths.status`, analytics timeline uses `berth_reservations` — two different numbers on same dashboard | `dashboard.service.ts` |
|
||||
| 2.7 | Revenue PDF unweighted vs dashboard weighted-by-`pipeline_weights` — will never reconcile | `report-generators.ts` |
|
||||
| 2.8 | `expenses.amountUsd` snapshot uses edit-time rate not `expenseDate`; nulls when Frankfurter is down | `expenses.service.ts` |
|
||||
| 2.9 | `convert()` rounds 2dp regardless of currency (JPY broken); invoice math has no rounding (sub-cent drift) | `currency.service.ts`, invoice math |
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Customer-visible polish (embarrassing in front of clients)
|
||||
|
||||
| # | What | Where |
|
||||
| ---- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
|
||||
| 3.1 | "Interest" / "lead" / "prospect" / "deal" used interchangeably in client-facing UI | `berth-detail-header.tsx`, `berth-tabs.tsx` "Deal Documents", `client-interests-tab.tsx`, `interest-tabs.tsx` |
|
||||
| 3.2 | Portal renders raw machine enums to clients ("EOI: waiting_for_signatures", "hot lead") | `/portal/interests/page.tsx:80` |
|
||||
| 3.3 | 16 destructive flows use native `window.confirm()` | cancel signing envelope, delete files, archive interest/company/yacht |
|
||||
| 3.4 | Signing-status labels diverge across 5 surfaces (Hub / list / interest-tab / SigningProgress / notification-digest / realtime-toast) | normalize through one helper |
|
||||
| 3.5 | 6× "Save" button variants ("Save" / "Save Changes" / "Save changes") + 6× "Saving..." vs "Saving…" | sweep |
|
||||
| 3.6 | Live Documenso template missing `Berth Range` field — every multi-berth EOI ships with primary mooring only | Documenso admin |
|
||||
| 3.7 | URL interpolations in every email template are unescaped (`href="${data.link}"`) — a `"` in any URL breaks out | escape + scheme allow-list in `shell.ts` |
|
||||
| 3.8 | Admin email-template subject editor silently does nothing on 5 of 8 templates | wire `overrides.subject` |
|
||||
| 3.9 | `/admin/email` Signature/Footer HTML fields write keys the shell never reads | wire `cfg.footerHtml` or delete fields |
|
||||
| 3.10 | Mobile scan PWA "Save expense" sits flush against iPhone home indicator | safe-area-inset on ScanShell `<main>` |
|
||||
|
||||
---
|
||||
|
||||
## Tier 4 — Authz / cross-tenant integrity
|
||||
|
||||
| # | What | Where |
|
||||
| --- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 4.1 | Port admin with only `admin.manage_users` can grant other users any leaf they don't hold themselves (sock-puppet escalation) | permission-overrides PUT + `updateUser` role reassignment — require caller-superset before write |
|
||||
| 4.2 | `/api/v1/alerts` GET is ungated | add `admin.view_audit_log` |
|
||||
| 4.3 | Webhooks bypass the platform-error pipeline entirely | `documenso/route.ts` — `captureErrorEvent` on handler throw, apply to all webhook routes |
|
||||
| 4.4 | Search graph-expansion writes into all merged buckets without re-checking per-bucket `view` permission | `search.service.ts:1893-1915` — gate each merge call |
|
||||
| 4.5 | "Convert to client" writes prefill qs params no consumer reads; inquiry_id linkage dropped forever | inquiry-inbox triage flow |
|
||||
| 4.6 | Inquiry email dedup is case-sensitive (capital-letter resubmits = duplicate client+yacht+interest) | `lower()` on `clientContacts.value === data.email` |
|
||||
|
||||
---
|
||||
|
||||
## Tier 5 — Concurrency / data races
|
||||
|
||||
| # | What | Where |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| 5.1 | `handleDocumentCompleted` idempotency gate is TOCTTOU under webhook+poll race — duplicate files rows + orphan blob | `documents.service.ts:1100-1253` — `SELECT … FOR UPDATE` or pre-claim transition |
|
||||
| 5.2 | **Zero BullMQ `jobId` usage repo-wide** — every queue.add is unkeyed, any double-fire creates a duplicate job | every `queue.add` site |
|
||||
| 5.3 | `advanceStageIfBehind` reads stage outside any lock — parallel DOCUMENT_SIGNED + DOCUMENT_COMPLETED double-run berth rules | wrap in tx |
|
||||
| 5.4 | `moveFolder` cycle check outside a tx — two concurrent moves can create A↔B cycles | wrap in tx |
|
||||
| 5.5 | Berth-PDF upload writes blob _before_ acquiring advisory lock — orphans on tx-rollback | reorder |
|
||||
| 5.6 | `user_email_changes` has no partial unique index on pending rows — spam-email vector | add partial unique |
|
||||
|
||||
---
|
||||
|
||||
## Tier 6 — Perf / scale (silent today, painful at 10× traffic)
|
||||
|
||||
| # | What | Where |
|
||||
| --- | ----------------------------------------------------------------------------------------------------------- | ---------------------- |
|
||||
| 6.1 | Documents tab opens with ~50 sequential queries via fetchWorkflowGroupRows | `documents.service.ts` |
|
||||
| 6.2 | Recharts statically imported in `widget-registry.tsx` — every dashboard chart in initial bundle (~80-150KB) | lazy import |
|
||||
| 6.3 | `DataTable` rebuilds `allColumns` every render (no useMemo) — resets TanStack internal state | memo |
|
||||
| 6.4 | `tiptap-to-pdfme.ts` (571 lines) ships to client just to re-export TEMPLATE_VARIABLES | split |
|
||||
| 6.5 | `listUsers` runs 2 sequential queries with no pagination, returns all super-admins globally | paginate |
|
||||
| 6.6 | `command-search` invalidates 2 queries every dropdown open — defeats its own 30s staleTime | drop invalidates |
|
||||
|
||||
---
|
||||
|
||||
## Tier 7 — Build / deploy hardening
|
||||
|
||||
| # | What | Where |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------- | -------------- |
|
||||
| 7.1 | No `.dockerignore` → 7.6 GB build context, secrets/.env leak risk via `COPY . .` | add |
|
||||
| 7.2 | `socket.io` + `@socket.io/redis-adapter` not in `serverExternalPackages`; runner stage installs no runtime deps | next.config.ts |
|
||||
| 7.3 | Prod CSP keeps `'unsafe-inline'` on script-src | tighten |
|
||||
| 7.4 | `Dockerfile.dev` runs as root | non-root user |
|
||||
| 7.5 | Compose has no memory/CPU/log-rotation limits | add |
|
||||
| 7.6 | `@types/node@^25` against Node-20 runtime — type checker greenlights APIs that don't exist | pin to ^20 |
|
||||
| 7.7 | `node:20-alpine` base image at/past EOL | bump to 22 |
|
||||
|
||||
---
|
||||
|
||||
## Tier 8 — Already fixed in this session (don't redo)
|
||||
|
||||
Already on `feat/documents-folders`:
|
||||
|
||||
- Permission-overrides self-target privilege escalation block + canonical allow-list + cross-tenant guard
|
||||
- `/api/auth/resolve-identifier` rate-limit + synthetic miss email
|
||||
- Admin email-change updates `account.accountId` + revokes sessions
|
||||
- Middleware `PUBLIC_PATHS` for email confirm/cancel tokens
|
||||
- NAV_CATALOG dead-link sweep (10 entries)
|
||||
- formatRole / formatOutcome / stageLabel applied across user-list, user-card, role-list, sidebar, command-search, realtime-toasts, interest-detail-header, client-columns, yacht-tabs, interest-picker, next-in-line-notify, AI worker, PDF reports
|
||||
- Optional username sign-in (migration 0054)
|
||||
- Per-user permission overrides (migration 0055) + UserPermissionMatrix
|
||||
- UserForm: first/last + admin email change + auto-notify template + PhoneInput
|
||||
- User disable button
|
||||
|
||||
---
|
||||
|
||||
## Tier 9 — Nice-to-haves + AI opportunities (not blocking)
|
||||
|
||||
Forward-looking (improvements-auditor):
|
||||
|
||||
- **AI-where-it-actually-helps:** semantic search across notes + email threads, auto-summarise client history on detail-page open, anomaly detection on expenses paired with existing OCR.
|
||||
- **What NOT to AI-ify:** legal docs, EOI/contract field merges, money flow, regulatory text.
|
||||
- **Subtle UX wins:** keyboard shortcuts (j/k list nav, e to edit), smarter defaults (last-used port/currency/source), undo for accidental archives, "what changed since I last looked" digest.
|
||||
|
||||
---
|
||||
|
||||
_Pick a tier and we open it._
|
||||
203
docs/BACKLOG.md
203
docs/BACKLOG.md
@@ -4,12 +4,13 @@
|
||||
asking "what's left to build/fix?". Items are grouped by source doc;
|
||||
each entry links back to the original spec for full context.
|
||||
|
||||
Last updated: 2026-05-08 (second non-Documenso sweep — storage-proxy
|
||||
port-binding, system_settings NULLS NOT DISTINCT + dedup migration,
|
||||
response-shape standardization, parseBody migration, custom-field merge
|
||||
tokens, /api/v1/files companyId+yachtId filter, Company Documents tab,
|
||||
file-upload zone wired for company/yacht targeting). Documenso phases
|
||||
2-7 stay back-burnered per user.
|
||||
Last updated: 2026-05-12 (PDF stack overhaul shipped: react-pdf brand
|
||||
kit + port logo upload + 4 reports + 3 record exports + parent-company
|
||||
expense + pdfkit brand header + invoice removal + tiptap-to-pdfme
|
||||
deletion + unpdf for berth-parser tier-2; pdfme deps removed.
|
||||
Remaining 7 react-email templates ported. browser-image-compression
|
||||
wired into scan-shell. @axe-core/playwright smoke suite added.).
|
||||
Documenso phases 2-7 stay back-burnered per user.
|
||||
|
||||
---
|
||||
|
||||
@@ -41,7 +42,7 @@ Remaining phases — explicitly back-burnered by the user on 2026-05-07:
|
||||
- ✅ **Merge tokens** — `{{custom.<fieldName>}}` validators + resolver shipped 2026-05-08. Tokens expand at template-render time for client/interest/berth contexts via `mergeCustomFieldValues` in `document-sends.service.ts`. Banner updated.
|
||||
- **Search index** — DEFERRED as design limitation. Adding GIN coverage requires either joining `custom_field_values` per search (slow at scale) or materializing values into a search_text column on the parent (additive maintenance burden). The amber banner documents this.
|
||||
- **Audit diff** — N/A. Custom-field values live in their own table, not as a JSONB blob on the parent entity. The `setValues()` service-layer call already creates its own audit log entry (custom-fields.service.ts:349-358), so changes ARE audited — just separately from the entity-diff.
|
||||
- **UI surfacing of `{{custom.…}}` tokens in template-edit pickers** — Open. The token list dialog currently only shows static catalog tokens. Surface per-port custom-field definitions as a dynamic group under "Custom" so reps can browse them. Backend already accepts the tokens; this is a UI follow-up.
|
||||
- ✅ **UI surfacing of `{{custom.…}}` tokens in template-edit pickers** — landed 2026-05-13. Shared `<TemplateTokenPicker>` (`src/components/admin/shared/template-token-picker.tsx`) renders the canonical `MERGE_FIELDS` catalog grouped by scope plus a dynamically-fetched "Custom (port-specific)" group filtered to entityTypes resolvable at send-time (client/interest/berth). Wired into both `sales-email-config-card.tsx` and `document-templates/template-form.tsx` so both pickers share the same surface.
|
||||
|
||||
---
|
||||
|
||||
@@ -128,6 +129,194 @@ instances, or cross-cutting refactors:
|
||||
|
||||
---
|
||||
|
||||
## G. Dependencies / audit roadmap (post-PDF-overhaul)
|
||||
|
||||
**Source:** [`docs/AUDIT-2026-05-12.md`](./AUDIT-2026-05-12.md) §§ 34-36 +
|
||||
[`docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md`](./superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md).
|
||||
|
||||
What's done (2026-05-12 session — all phases shipped):
|
||||
|
||||
- ✅ **PDF stack overhaul** — `@react-pdf/renderer` + brand kit + port logo upload pipeline; 4 reports + 3 record exports + parent-company expense ported; pdfme uninstalled; pdfkit retained for streaming expense PDF (now with shared brand-header). Invoice PDF generation removed (deferred to AcroForm-fill admin-upload). TipTap-to-pdfme bridge (571 LOC) deleted; admin TipTap templates remain as Documenso seed bodies. `unpdf` wired into berth-PDF parser tier-2 (replaced broken tesseract-on-PDF path).
|
||||
- ✅ **react-email templates** — all 7 remaining (crm-invite, document-signing×3, inquiry×2, residential×2, notification-digest, admin-email-change) ported from string templates to React components. Public API surface now `async`. The whole email template directory is uniformly react-email.
|
||||
- ✅ **browser-image-compression** — wired into scan-shell so 4-12 MB phone photos crush to ~500 KB in a WebWorker before tesseract / upload. Massive mobile bandwidth + battery + perceived-latency win.
|
||||
- ✅ **@axe-core/playwright** — smoke spec runs WCAG 2.1 A/AA against 6 main pages; CI fails on new critical/serious violations.
|
||||
- ✅ **ts-pattern in search.service.ts** — converted both switches to `match().with().exhaustive()`; surfaced a real bug along the way (missing `notes` bucket dispatch — `searchNotes()` existed but was never wired into runSingleBucket). The audit flagged 3 other switch sites (client-restore, recently-viewed, custom-fields); those operate on tagged-union internal types where TypeScript already enforces exhaustiveness via control-flow narrowing — converting them adds noise without changing safety. **Done.**
|
||||
- ✅ **p-limit in mass-op services** — bounded fan-outs on the three real unbounded `Promise.all` sites the audit flagged: berth-pdf S3 presigns (20-version berths), custom-fields bulk upserts (50-definition admin scenarios), notifications watcher fan-out (hot pipeline items). Audit also speculatively flagged brochures.service + backup.service — verified neither has an unbounded fan-out. **Done.**
|
||||
- ✅ **formatDate helper** — single source of truth in `src/lib/utils/format-date.ts` backed by `Intl.DateTimeFormat` (no new dep). 9 named presets, TZ-aware via `tz` opt, defensive against null/Invalid Date. `formatDateRange` collapses same-year strings. `formatRelative` via `Intl.RelativeTimeFormat`. 17 unit tests. Sample sweep through 3 high-traffic sites (expense-pdf header, 3 document-template merge tokens); the remaining 93 `.toLocale*` sites can be migrated opportunistically when each file is touched.
|
||||
- ✅ **@tanstack/react-virtual in DataTable** — opt-in `virtual` prop. Existing server-paginated tables unchanged; large client-side lists (admin exports, audit-log archive) now render only viewport rows + small overscan at 60 fps. Pagination wins over virtual when both are passed; mobile card view untouched; sticky header, sort, selection all unchanged.
|
||||
- ✅ **drizzle-zod adoption** — pattern proven in tags.ts + brochures.ts (earlier commit). The remaining ~28 validators include heavy form-input transforms (numeric-string-to-null, refined business rules, partial omits/picks) that drizzle-zod's createInsertSchema doesn't preserve — most are NOT 1:1 with the table shape. Migration is net-wash on LOC and adds no safety. Pattern available for adoption when a validator genuinely matches its table.
|
||||
- ✅ **Tier 2 polish** — surveyed each candidate. `fast-deep-equal` not needed (existing memo comparators work). `use-debounce` package adds no value over the in-tree 13-LOC hook. `@use-gesture/react`, `embla-carousel-react`, `yet-another-react-lightbox`, `react-resizable-panels` all need concrete UX surfaces or product decisions before wiring — added them to the parked list.
|
||||
- ✅ **Pre-commit staged type-check** — `scripts/tsc-staged.mjs` (30-LOC shim) replaces the broken `tsc-files` package (which silently no-ops under pnpm). Pre-commit now runs `tsc -p <temp-config>` against staged ts/tsx in ~3s vs ~22s full-project; type errors caught before they hit CI.
|
||||
|
||||
**React Compiler safety triage (post-Next-16 bump):**
|
||||
|
||||
The Next 15 → 16 upgrade brought `react-hooks` v7 with React Compiler safety rules. Initial sweep surfaced ~89 findings; categorical triage status as of 2026-05-12:
|
||||
|
||||
- ✅ `react-hooks/purity` (2 → 0) — promoted to `error`. Cleared by pinning `Date.now()` reads to a `useState`-backed `now` ticker in `notes-list.tsx`.
|
||||
- ✅ `react-hooks/set-state-in-render` (5 → 0) — promoted to `error`. `useMemo` mis-used for side effects in `interest-contact-log-tab.tsx`; converted to `useEffect`.
|
||||
- ✅ `react-hooks/immutability` (7 → 0) — promoted to `error`. Mutable `useMemo` value in `documents-hub.tsx` drag counter → `useRef`. `let angle` mutation in `PieChart.tsx` slice loop → `reduce`. Three "function used before declared" hits (load/loadProfile in admin/onboarding-checklist + settings/user-profile + settings/user-settings) → declared inside the calling `useEffect`.
|
||||
- ✅ `react-hooks/refs` (10 → 0) — promoted to `error`. Three `ref.current = x` writes during render moved into a layout-effect (`use-realtime-invalidation.ts`, `settings-form-card.tsx`, `inbox.tsx`). Three search-related `ref.current` reads during render rewritten to backed-by-state (`command-search.tsx`, `mobile-search-overlay.tsx`). Scan shell's `fileRef.current.files[0]` read replaced with a tracked `currentFile` state.
|
||||
- ✅ `react-hooks/incompatible-library` (13 → silenced as `off`) — purely informational ("Compiler skipped this file because of a non-Compiler-safe import"). No action needed.
|
||||
- ✅ `react-hooks/set-state-in-effect` (51 → 0) — promoted to `error` in eslint.config.mjs. All admin-form data-loading hits migrated to TanStack Query (`useQuery`); a small ring of justified eslint-disable comments cover canonical setState-on-subscription patterns (socket-provider, carousel, settings-form-card, etc.). New regressions block CI.
|
||||
|
||||
**Data-fetching pattern migration: DONE.** All `useEffect → fetch → setState` sites in admin components migrated to TanStack Query. `set-state-in-effect` is now an ESLint error, so new regressions can't land.
|
||||
|
||||
---
|
||||
|
||||
Remaining (opportunistic, no concrete trigger):
|
||||
|
||||
| Item | Estimate | Notes |
|
||||
| --------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`.toLocale*` remainder (93 sites)** | ~2-3h opportunistic | Migrate to `formatDate(...)` as you touch each file. Helper already shipped; 17 tests; sweep proven on PDF + template paths. |
|
||||
| **drizzle-zod remainder (~28 simple validators)** | ~30 min per file | Migrate when a validator file is touched. Pattern proven in tags + brochures. |
|
||||
| **Wire `<DataTable virtual />`** on big tables | ~15 min per site | Prop is shipped + opt-in. Apply to: admin/audit-log-list (10k rows possible), super-admin port switcher (50+ ports), client export modal preview. None blocking. |
|
||||
| **Tier 2 polish — when product UX surfaces emerge** | each 30 min – 1 h | `embla-carousel-react` + `yet-another-react-lightbox` for berth / yacht photo galleries · `react-resizable-panels` for docs hub sidebar · `@use-gesture/react` for kanban swipe. |
|
||||
|
||||
Decisions / parked:
|
||||
|
||||
- ~`@upstash/ratelimit`~ — **rejected on inspection.** Audit claimed "4 hand-rolled rate limiters"; actual state is **one** centralized sliding-window Redis limiter (`src/lib/rate-limit.ts`) with 14 named policies + atomic pipeline. Replacement is pure churn.
|
||||
- ~`@faker-js/faker`~ — **rejected on inspection.** Both seed files (`seed-data.ts`, `seed-synthetic-data.ts`) are hand-curated demo specs (per-pipeline-stage clients with locale-correct names/phones/addresses keyed to test selectors). No fake-data factory exists to replace — adopting faker means WRITING the factory + losing curation. Net add, not net subtract.
|
||||
- ~`msw`~ — **rejected on inspection.** Integration tests already mock external services via `vi.mock('@/lib/services/documenso-client', ...)` at the module boundary — equivalent determinism, no extra layer. MSW only wins when tests hit `fetch()` directly, which we don't.
|
||||
- `next-safe-action` — pilot on a new form first (no concrete trigger).
|
||||
- `@sentry/nextjs` — needs SaaS-dep decision.
|
||||
- `@tiptap/core` upgrade — needs product decision on rich notes.
|
||||
- `pdfjs-dist` / `@react-pdf-viewer/core` — in-browser PDF preview in docs hub (paired with Phase 2 docs-hub UX work).
|
||||
- `next-pwa` / `@serwist/next` — icons already in `public/`; revisit only when we want fuller service-worker integration (offline shell, install prompt UX).
|
||||
- `next-intl` — no current i18n target.
|
||||
- `posthog-js` — analytics scope decision.
|
||||
- `react-virtuoso` — only useful if inbox grows past ~hundreds of items; current `<ScrollArea max-h-[400px]>` handles realistic volumes fine.
|
||||
- `react-imask` / `react-number-format` — input masks across ~6 forms. Decision pending: hand-rolled formatters work today.
|
||||
- `type-fest` — opportunistic types; no concrete trigger.
|
||||
- `partysocket` — Socket.IO-protocol incompatible without significant rework.
|
||||
|
||||
Major deferrals from §34 of audit:
|
||||
|
||||
- ~**Next 15 → 16**~ — **DONE 2026-05-12**. middleware.ts → proxy.ts via codemod, native flat eslint config, react-hooks v7 Compiler safety rules surfaced + triaged.
|
||||
- ~**Tailwind 3 → 4**~ — **DONE 2026-05-12**. Official upgrade tool migrated 80 files; tailwind-animate → tw-animate-css; theme moved to @theme directive in globals.css.
|
||||
- **eslint 9 → 10** — attempted, reverted: `eslint-config-next@16` still has a transitive on `eslint-plugin-react@7` that uses removed eslint-9 context API. Re-attempt when upstream lands eslint-plugin-react@8.
|
||||
- **archiver 7 → 8** — no `@types/archiver@8` published; skip indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## H. Grand audit cleanup plan (post-deps)
|
||||
|
||||
**Source:** [`docs/AUDIT-2026-05-12.md`](./AUDIT-2026-05-12.md) — 534 findings across 27 domain reports + [`docs/AUDIT-FOLLOWUPS.md`](./AUDIT-FOLLOWUPS.md) + [`docs/AUDIT-TRIAGE.md`](./AUDIT-TRIAGE.md).
|
||||
|
||||
Deps work is complete (sections A-G above). Remaining audit cleanup is grouped into focused waves so it's tackleable a chunk at a time. Each wave has clear scope, file pointers, and acceptance criteria.
|
||||
|
||||
### Wave 1 — Stop-ship CRITICALs (security + data integrity)
|
||||
|
||||
Roughly half-day each; ship in priority order. These are the items from the audit's `## Cross-cutting priority queue` marked `[C]`:
|
||||
|
||||
1. **Real `db:migrate` runner** — `0052_audit_critical_fixes.sql` uses `CREATE INDEX CONCURRENTLY` which silently never runs under `db:push`. Six composite indexes missing in prod. Build a tsx runner that reads migrations in order, splits on `--> statement-breakpoint`, executes outside a tx, tracks state in `__drizzle_migrations`. ~3-4 h. **(data-model C1)**
|
||||
2. **`EMAIL_REDIRECT_TO` production guard** — `src/lib/env.ts` should refine to reject when `NODE_ENV === 'production'`; `src/lib/email/index.ts` should `logger.warn` at boot. 5-min change, prevents a very-bad-day class of incident. **(email C1)**
|
||||
3. **Orphan-blob fix in `handleDocumentCompleted`** — `src/lib/services/documents.service.ts:1100-1253`. Wrap `storage.put + files.insert + documents.update` in a transaction (or saga with compensating delete). Current catch-block leaves blob in storage AND marks `status='completed'` with no `signedFileId`. ~2 h. **(services C2)**
|
||||
4. **Escape URLs in email templates** — every template in `src/lib/email/templates/*` inlines `${data.link}` etc. into `href="…"` and link text without escaping. Add `escapeUrl` helper + http(s) scheme allow-list; route every template through it. ~3 h. **(email C2)**
|
||||
5. **Replace 16 native `window.confirm()` calls** — destructive flows bypassing `ConfirmationDialog` / `AlertDialog`. ui-ux-auditor's C1 lists the sites (cancel signing, delete files, archive interest/company/yacht…). ~30 min per site = full day. **(ui/ux C1)**
|
||||
6. **GDPR Article-15 export completeness** — `src/lib/services/gdpr-bundle-builder.ts` is missing: portal_users, email_threads/messages, document_sends, reminders, files, scratchpadNotes, client_merge_log, contact_log, website_submissions, form_submissions. Regulator-finding-level gap. ~half-day. **(gdpr C1)**
|
||||
7. **Right-to-be-forgotten actually erase** — `src/lib/services/client-hard-delete.service.ts` nullifies FKs but leaves verbatim PII in `email_messages.body_html`, `files`, `document_sends.recipient_email`. Add true-wipe path. ~half-day. **(gdpr C2)**
|
||||
8. **`user_permission_overrides.user_id` FK + `onDelete='set null'`** — data-model H1+H2. Single migration. ~30 min. **(data-model H1+H2)**
|
||||
9. **Resolve-identifier endpoint replacement** — current rate-limited hit still echoes the real canonical email on a successful username hit. Replace with a server-side signIn proxy that takes `{identifier, password}` together and never returns canonical emails at all. ~2 h. **(security/gdpr crossover)**
|
||||
|
||||
### Wave 2 — HIGH-priority security + observability (5-7 days)
|
||||
|
||||
10. **`audit_logs.metadata` PII masking** — extend `maskSensitiveFields` to cover `audit_logs.metadata`; add 90-day retention cron mirroring `error_events`. ~2 h. **(gdpr H)**
|
||||
11. **Webhook → error pipeline** — `src/app/api/webhooks/documenso/route.ts` bypasses `captureErrorEvent` on handler crash. Apply to every webhook route. ~2 h. **(observability H)**
|
||||
12. **Admin email-template subject editor** — 5 of 8 templates ignore `overrides.subject`; admins see "Saved" with zero effect. Wire all 8. ~2 h. **(email H1+H2)**
|
||||
13. **Admin signature/footer fields** — `/admin/email` writes `email_signature_html` + `email_footer_html` which the email shell never reads. Either delete the UI or wire it. ~half-day. **(email H3)**
|
||||
14. **PII redaction in error pipeline** — `error_events.request_body_excerpt` sanitizer redacts password/token but not email/phone/name/dob/address. ~2 h. **(observability H + gdpr)**
|
||||
15. **Notification email worker XSS** — `src/lib/queue/workers/notifications.ts:65-71` interpolates `notif.description` and `notif.link` into HTML unescaped. Apply `escapeHtml` + URL allow-list (the `isomorphic-dompurify` we shipped helps here). ~1 h. **(email H + security)**
|
||||
|
||||
### Wave 3 — React Compiler set-state-in-effect cleanup (~40 sites remaining)
|
||||
|
||||
Remaining `react-hooks/set-state-in-effect` warnings: **40** (was 41; reduced 2026-05-13). Two patterns established this session as templates:
|
||||
|
||||
- **List/load pattern** (`src/components/admin/tags/tag-list.tsx` is the template): `useState([]) + useEffect(fetch+setState)` → `useQuery({ queryKey, queryFn })`. Mutation paths get `useMutation` with `onSuccess: queryClient.invalidateQueries`. ~10 min per site.
|
||||
- **Dialog open→reset pattern** (`src/components/clients/hard-delete-dialog.tsx` is the template; new exemplar: `src/components/documents/move-to-folder-dialog.tsx`): inner `<DialogBody key={id} ... />` mounted only while `open`, so `useState` initializers run naturally on each open without an open→reset useEffect. ~15 min per site.
|
||||
|
||||
Migrate as a focused day's work (~40 × 10-15 min), then promote `react-hooks/set-state-in-effect` from `warn` to `error` in `eslint.config.mjs` to lock in. **NOTE:** Warnings only — no functional regressions; promotion blocked solely until 0 warnings remain.
|
||||
|
||||
### Wave 4 — UI/UX consistency + accessibility (~3-4 days)
|
||||
|
||||
- ✅ **Raw enum render via `.replace(/_/g, ' ')` (40+ sites)** — extracted to `constants.ts` `formatStage`/`formatStatus`/`formatPriority` helpers (audit-wave-4). **(ui/ux H1)**
|
||||
- ✅ **18 list components missing mobile `cardRender`** — Wave 9.4 covered the 5 actual DataTable consumers without `cardRender` (admin/tags, admin/roles, admin/ports, admin/document-templates, admin/custom-fields). **(ui/ux H2)**
|
||||
- ✅ **Berth status pills using ad-hoc Tailwind colors** — swapped to shared `StatusPill` in Wave 9.2. **(ui/ux M1)**
|
||||
- ✅ **UserList "Active"/"Disabled" badge** — aligned to `StatusPill` in Wave 9.2; also `PortList` in Wave 9.4. **(ui/ux M2)**
|
||||
- ✅ **Drawer vs Sheet usage drift** — single offender (`client-interests-tab`) swapped to Sheet; doctrine documented in CLAUDE.md (Wave 9.1). **(ui/ux M11)**
|
||||
- ✅ **Decorative icons missing `aria-hidden`** — Wave 10.4 mechanical sweep added `aria-hidden` to 444 self-closing single-line Lucide icons across 267 .tsx files. **(ui/ux M10)**
|
||||
- ✅ **Hard-coded "border-amber-300 bg-amber-50" callouts (15+ sites)** — `<WarningCallout>` shipped in Wave 4. **(ui/ux L5)**
|
||||
- ✅ **Dashboard route `loading.tsx` coverage** — default `[portSlug]/loading.tsx` plus tailored detail-page skeletons (Wave 9.5). **(ui/ux M3)**
|
||||
|
||||
### Wave 5 — Performance + reliability (~2-3 days)
|
||||
|
||||
- ✅ **Concurrency races** — Wave 10.3 closed the CRITICAL + tractable HIGH items: `handleDocumentCompleted` concurrent-retry TOCTOU via SELECT FOR UPDATE re-check (C-1), `moveFolder` cycle-check race via per-port pg_advisory_xact_lock (H-1), `upsertInterestBerth` 23505 → ConflictError (H-3), username uniqueness 23505 → ConflictError (M-2). Wide-impact items (BullMQ jobId plumbing — C-2) remain deferred. **(concurrency C, H)**
|
||||
- ✅ **Postgres FTS for `search.service.ts`** — migration `0057_search_fts_indexes.sql` shipped in Wave 5. **(audit 36.K.1)**
|
||||
- ✅ **`useEffect → fetch → setState` data-loading** — covered by Wave 3.
|
||||
|
||||
### Wave 6 — Email + Documenso depth (~2-3 days)
|
||||
|
||||
- **Documenso integration depth** (documenso-auditor report) — full v1/v2 audit, recipient signing URL handling, redirect URL per-port, sequential signing flag.
|
||||
- **Email deliverability** (email-auditor report) — subject editor wire-up (Wave 2 #12), signature/footer wire-up (Wave 2 #13), bounce monitoring sanity check, attachment threshold UX.
|
||||
|
||||
### Wave 7 — Reporting + recommender quality (~half-week)
|
||||
|
||||
- **Reporting math correctness** (reporting-auditor) — verify revenue, pipeline funnel, occupancy math against hand-computed truth set.
|
||||
- **Berth recommender quality** (recommender-auditor) — tier ladder edge cases, heat-score weight calibration.
|
||||
|
||||
### Wave 8 — Long tail (whenever)
|
||||
|
||||
- ✅ **PDF + brand asset correctness** (pdf-auditor) — Wave 9.6: wrong-port brand fallback (`'Port Nimara'` → `(port)`/throw), AcroForm field-drift warnings, EOI form flatten, PDF metadata, sha256 pinning of `assets/eoi-template.pdf`, berth-range warning noise. Items C-2/C-3 (tiptap-to-pdfme bugs) were eliminated by the 2026-05-12 PDF stack overhaul.
|
||||
- ✅ **Customer-facing copy + terminology** (copy-auditor) — Wave 9.7: centralized `lib/labels/document-status.ts` (C3), portal `leadCategory` chip removed (C2), `Save Changes` → `Save changes` + `Saving...` → `Saving…` codemod (H1, M3), envelope → signing request (M1), `Linked prospect` → `Linked interest`, `Deal Documents` → `Interest Documents`, `Hot Lead` → `Hot lead` (M5).
|
||||
- ✅ **Onboarding + first-run UX** (onboarding-auditor) — Wave 9.8: fixed wrong setting keys in checklist auto-checks (C1), broken `forms` href (C2), compound gate for Documenso EOI readiness (C3), catch-and-log around `ensureSystemRoots` (C4), fresh-port berth empty state (H5), admin-sections-browser description (M4).
|
||||
- ✅ **Type-safety + drizzle leak audit** (types-auditor) — Wave 10.1: `Tx` type exported (C-1), berth-detail `useQuery<any>` replaced with `BerthDetailData` (C-2), parseBody adopted across 7 portal/public routes (C-3), `toAuditJson<T>` helper removed 21 `as unknown as Record<…>` casts (H-5). Drizzle leak check came back clean (no `$inferSelect` crossing the API boundary).
|
||||
- ✅ **Build + deploy + prod readiness** (build-auditor) — Wave 10.2: socket.io + 6 other native deps added to `serverExternalPackages` + COPY-in-Dockerfile (C-3), `NEXT_PUBLIC_APP_URL` validation (H-2), healthcheck PORT templatization (H-5), `NODE_ENV=production` in builder (M9), image-level HEALTHCHECK (M7). CSP `'unsafe-inline'` (H-1) deferred pending nonce middleware infrastructure.
|
||||
- ✅ **Wave 11 — unaddressed-dossier sweep + cross-cutting infra**:
|
||||
- **BullMQ jobId plumbing** (concurrency C-2): stable per-entity jobIds added across `invoices` (send-invoice, invoice-overdue-notify), `gdpr-export`, `webhook-dispatch`, `expenses`, `webhooks.service`, `notifications`, `inquiry-notifications`, `reports` (generate-report).
|
||||
- **CSP nonce middleware** (build-auditor H-1): per-request nonce in `src/proxy.ts:buildCspWithNonce` with `'self' 'nonce-<n>' 'strict-dynamic'` in prod; `next.config.ts` fallback header kept for static assets / API JSON.
|
||||
- **Error UX** (error-ux-auditor): `apiFetch` synthesizes a client-side correlation id for non-JSON 5xx (C3); `checkRateLimit` fails open on Redis outage so auth doesn't lock (C4); `StorageTimeoutError extends Error` with `name='TimeoutError'` for classifier hints (H2); `errorResponse()` adopted across `/api/storage/[token]`, `/api/public/website-inquiries`, Documenso webhook body cleaned (H5); 17 `toast.error(err.message)` sites swept to `toastError(err, …)` (C2).
|
||||
- **Outbound webhooks** (outbound-webhook-auditor): Stripe-style `HMAC(secret, "${ts}.${body}")` + `X-Webhook-Timestamp` header (C1); dead-letter when secret is null (C3); retry policy `8 attempts × 30s base exponential` (H2); SSRF denylist gains Oracle Cloud `192.0.0.192` (M1); dispatch-time `https://` assertion (M2).
|
||||
- **Storage-pathing** (storage-pathing-auditor): berth-PDF presigned-upload key prefixed with `${portSlug}/` + `portSlug` passed to `presignUpload` (H1); `presignDownloadUrl` infers the slug from the key's first segment when callers don't pass it explicitly — engages the filesystem-proxy port-binding `p` token verifier across every download site (H2).
|
||||
- **Search** (search-auditor): dead `void wantEmail; void wantPhone;` + unused `looksLikeEmail` helper removed (H3).
|
||||
- **Maintainability** (maintainability-auditor M2): swept seven `void <symbol>` abandoned-scaffolding markers and their dead imports across `clients/bulk`, `interests/bulk`, `admin/email-templates`, `admin/website-submissions`, `alert-rules`, and `notes.service`.
|
||||
|
||||
### Wave 11 — explicitly deferred items (revisited 2026-05-13, deferred again)
|
||||
|
||||
Each was flagged by the audit but assessed as not-yet-needed for production correctness. Listed here so future-you doesn't re-research them.
|
||||
|
||||
**Engineering refactors deferred:**
|
||||
|
||||
- **Orphan-blob reaper** (storage-pathing C2, ~4-6h) — `handleDocumentCompleted` already has compensating delete for the only frequent orphan path. Other paths (gdpr-export, backup, etc.) are low-frequency. Revisit when storage costs grow.
|
||||
- **Webhook deliveries reaper** (outbound-webhook C2, ~2-3h) — `webhook_deliveries` table grows unbounded on high-volume events. Zero active webhook subscribers today; revisit when customers actually subscribe.
|
||||
- **DNS-rebind TOCTOU** (outbound-webhook H1, ~2h) — Requires admin AND DNS control on the target host. Defense-in-depth on already-low-risk vector. Revisit before exposing webhooks to external integrators.
|
||||
- **Streaming pass on backup/migrator/email-compose** (storage-pathing H3+H4, ~4-6h) — pg_dump OOM at multi-GB. DB is ~10s of MB today. Revisit when DB grows 100x.
|
||||
- **Webhook circuit-breaker** (outbound-webhook H3, ~3-4h) — Auto-disable webhooks after N consecutive dead-letters. Saturating worker slots requires active webhook subscribers; none today.
|
||||
|
||||
**Mechanical service splits deferred:**
|
||||
|
||||
- `documents.service.ts` split (1982 lines → 4 files, ~3-4h)
|
||||
- `search.service.ts` split (2163 lines → per-bucket files, ~4-6h)
|
||||
- `notes.service.ts` dedup → dispatch table (1121 → ~500 lines, ~3-4h)
|
||||
- `interest-tabs.tsx` split (959 lines → 3 files, ~2-3h)
|
||||
- `expense-pdf.service.ts` split (987 → 3 files, ~2h)
|
||||
- `command-search.tsx` split (1177 → 5 files, ~3-4h)
|
||||
|
||||
Pure code-hygiene work. The files are large but functional. Splitting touches hundreds of imports, risks regression, delivers zero user value. Revisit if/when navigation friction becomes a real bottleneck.
|
||||
|
||||
### How to use this section
|
||||
|
||||
- Pick a wave; pick an item; read the linked audit section for full context.
|
||||
- Each item closes with a commit in the `fix(audit-<wave>): ...` format so it's trivially greppable.
|
||||
- Mark items DONE inline in this section as they ship.
|
||||
- Audit-FOLLOWUPS.md tracks Wave 1-10 from an earlier sweep — items there may already be done or supplanted by AUDIT-2026-05-12.
|
||||
|
||||
Future PDF-related work (carry-over from §A of the PDF overhaul spec):
|
||||
|
||||
- **AcroForm-fill admin-uploaded PDF templates** (~1 week solo): new `pdf_templates` table + admin upload UI + field-mapping editor + generalize `fill-eoi-form.ts` into a reusable `fillAcroForm()` utility. Reinstates the invoice PDF path (and any future customer-facing standardized doc).
|
||||
- **Port brand color tokens** (~2 h): admin sets brand color → flows into the PDF brand kit accent.
|
||||
- **Optical receipt-photo rotation/deskew** (~half day): auto-rotate phone-upload receipts that EXIF misses.
|
||||
|
||||
---
|
||||
|
||||
## F. Historical audit docs (mostly resolved)
|
||||
|
||||
These dossiers drove the audit-fix commit waves on 2026-05-05/06. Items
|
||||
|
||||
243
docs/PRE-DEPLOY-PLAN.md
Normal file
243
docs/PRE-DEPLOY-PLAN.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Pre-deploy plan — locked 2026-05-14
|
||||
|
||||
Source of truth for everything between today and initial VPS deployment.
|
||||
Captures every decision reached in the 2026-05-14 planning session, plus
|
||||
the implementation order, deferred items, and operator checklist.
|
||||
|
||||
If a future agent or session resumes this work, **start here** — do not
|
||||
re-litigate the decisions below without checking the transcript context
|
||||
that produced them.
|
||||
|
||||
---
|
||||
|
||||
## 1. Decisions
|
||||
|
||||
### 1.1 Hot-path correctness (numbers users see)
|
||||
|
||||
| # | Item | Decision | File(s) impacted |
|
||||
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Pipeline value mixed-currency | Convert each `berths.price` to the port-default currency at display time via `currency.service`, then sum. | `src/lib/services/dashboard.service.ts`, `src/components/dashboard/*` |
|
||||
| 2 | "Active interest" definition | `archivedAt IS NULL AND outcome IS NULL` (strictest). Won deals are CLOSED, not active. Extract single `activeInterestsWhere(portId)` SQL helper; route every site through it. | Sweep target — see § 2.1 for list. |
|
||||
| 3 | Occupancy source of truth | `berth.status = 'sold'`. KPI tile + revenue PDF + analytics timeline all derive from this one source. | `src/lib/services/dashboard.service.ts`, `src/lib/services/analytics.service.ts`, `src/lib/services/report-generators.ts` |
|
||||
| 4 | Revenue PDF shape | Two side-by-side cards on the same page: "Completed revenue (won, gross)" + "Forecast revenue (pipeline-weighted)". Stacks gracefully on portrait. | `src/lib/services/report-generators.ts` |
|
||||
| 4.5 | Multi-berth EOI mooring rendering | Populate the existing Documenso `Berth Number` form field with `eoiBerthRange` for both single- and multi-berth EOIs (single-berth output is identical to today via `formatBerthRange(['A1']) === 'A1'`). Drop the unused `Berth Range` payload key + AcroForm field + merge token. No Documenso admin action needed. | `src/lib/services/documenso-payload.ts`, `src/lib/pdf/fill-eoi-form.ts`, `src/lib/templates/merge-fields.ts`, `CLAUDE.md` |
|
||||
|
||||
### 1.2 Security / deploy gates
|
||||
|
||||
| # | Item | Decision |
|
||||
| --- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 5 | Portal activation + password-reset token URLs | Switch `?token=ABC` → `#token=ABC` (URL fragment). Fragment never hits server logs, proxies, or `Referer` header. Touches email templates + `/portal/activate` + `/portal/reset-password` + the `set-password` page reader. |
|
||||
|
||||
### 1.3 Email infrastructure refactor
|
||||
|
||||
| # | Item | Decision |
|
||||
| --- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 6 | Admin "Signature HTML" field | **Delete** it. Currently writes `email_signature_html` to settings; `shell.ts` only reads `emailFooterHtml`. Footer covers brand sign-off; signatures are semantically per-user (separate future feature if asked). |
|
||||
| 7 | Per-category send-from routing | New admin matrix on `/admin/email`: each email category (account activation, password reset, notification digest, EOI signing request, brochure send, berth-PDF send, signed-doc completion, sales send-out, manual rep compose) gets a sender dropdown (`noreply` / `sales`). Sales option auto-disabled when sales SMTP/IMAP creds aren't set. |
|
||||
| 8 | Bounce monitoring | Per-port admin-configurable IMAP polling of one or more sender mailboxes. Parses DSN bounce notifications via `mailparser`. Writes to new `email_bounces` table, flags the original `document_send` / `notification` / `email_thread` message as bounced, and emits an in-app notification to the assigned sales rep when a _client_ email bounces. |
|
||||
| 9 | Attachment threshold compose UI | On the manual-compose dialog (brochure send, berth-PDF send, rep custom email), show a banner on any attached file above `email_attach_threshold_mb` that says "will be sent as a 24h signed-link download instead of inline attachment". Also audit current default threshold (10MB) against typical SMTP provider caps. |
|
||||
|
||||
### 1.4 Schema additions
|
||||
|
||||
| # | Item | Decision |
|
||||
| --- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 10 | `berths.archived_at` column | Add `archived_at` (timestamp, nullable) + partial index on `(port_id) WHERE archived_at IS NULL`. Filter `/api/public/berths` to exclude archived. Add `<ArchiveBerth>` action in berth detail header (soft-delete with audit log). |
|
||||
| 11 | `clients.metadata.source_inquiry_id` | Add field for inquiry → client linkage so the conversion funnel chart can attribute won deals back to the originating inquiry. |
|
||||
| 12 | `email_bounces` table | Bounce monitoring storage — see #8. Columns: `id`, `port_id`, `mailbox_address`, `bounced_address`, `original_send_type` (enum: `document_send` / `notification` / `email_thread`), `original_send_id`, `dsn_status`, `dsn_action`, `dsn_diagnostic`, `received_at`, `raw_message`. |
|
||||
| 13 | Bulk-berth UX | 2-step wizard for new-port setup. Step 1: pick dock letter + range + tenure (only genuinely-standard defaults). Step 2: editable table with "apply to selected" multi-row actions + Excel-style drag-fill on numeric columns. Step 3 from earlier rounds folded in. |
|
||||
|
||||
### 1.5 UX features
|
||||
|
||||
| # | Item | Decision |
|
||||
| --- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 14 | "Mark as signed externally" action | On contract / reservation tabs: new action that records the document as signed without uploading a file. Captures optional reason in a warning modal. Advances pipeline + writes audit log. UI shows "⚠ No file on record — signed externally" indicator. Reps can later upload the file if they obtain a copy. |
|
||||
| 15 | Contract paper-upload endpoint | Clone the existing EOI `external-eoi` upload flow into `external-contract` and `external-reservation` endpoints. Mirrors the current EOI ergonomics. |
|
||||
| 16 | Inquiry P-4.5 wire-up | Make `/clients/new?prefill_*&inquiry_id=...` hydrate the create-client form from the searchParams **and** persist `inquiry_id` to `clients.metadata.source_inquiry_id`. Conversion funnel chart depends on this linkage. |
|
||||
| 17 | Quick brochure/PDF download | Add "Download" buttons on client detail header, interest detail header, berth detail header. Each downloads the current brochure (port-default) / berth PDF / signed contract from storage so the rep can attach to their own email or messenger app. |
|
||||
| 18 | Per-user reminder digest schedule | Build the simple version of `scheduler.ts:44` placeholder. User-settings dropdown for digest time + days-of-week. Falls back to port-default when unset. |
|
||||
| 19 | Documents tab N+1 batch fix | Replace the 4-call sequential walk in `listFilesAggregatedByEntity` (direct + company + yacht + client) with a single UNION query keyed by entity-relationship. Target: opening Documents tab on a busy client ≤500ms. |
|
||||
|
||||
### 1.6 Investor dashboard charts (toggleable widgets)
|
||||
|
||||
Priority order. Each chart ships as a separate widget integrated into the existing widget-customization system; disabled by default for reps, enabled by default for admins.
|
||||
|
||||
| # | Widget | Notes |
|
||||
| --- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 20 | Total pipeline value of all berths | Single big number (port-default currency, conversion at display). Weekly-change sparkline below. Re-uses the #1 currency-conversion helper. |
|
||||
| 21 | Berth interest heatmap + ranked-table view | Heatmap shows pier-style grid colored by active-interest count per berth. Paired with a sortable ranked-table view of the same data — table is what exports cleanly to PDF/CSV. Both views toggleable. |
|
||||
| 22 | Pipeline velocity over time | Stacked area chart: count of interests in each pipeline stage, weekly. Investors see whether deals are advancing or stalling. |
|
||||
| 23 | Conversion funnel by lead source | Enquiry → qualified → EOI → contract → won, broken down by `lead_source`. Depends on #16 (inquiry → client linkage) for full attribution. |
|
||||
|
||||
### 1.7 Mechanical sweeps
|
||||
|
||||
| # | Item | Decision |
|
||||
| --- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 24 | "Deal" → "interest" terminology sweep | Full sweep. Updates: admin description copy (`/admin/qualification-criteria`, `/admin/documenso`), `bulk-archive-wizard.tsx` placeholders, `smart-archive-dialog.tsx`, `client-columns.tsx` comments, and the API route path `/api/v1/berths/[id]/deal-documents` → `/api/v1/berths/[id]/interest-documents`. Route rename includes caller updates + a 301 redirect on the old path for any external integrations. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Implementation order
|
||||
|
||||
Branch: **`main`** (feat/documents-folders has been fast-forwarded into main; new work continues on main directly).
|
||||
|
||||
Test strategy: TDD-where-meaningful (services with behavioral changes — active-interest helper, currency converter, DSN parser). UI and mechanical sweeps covered by full vitest + tsc + lint + playwright smoke at the end.
|
||||
|
||||
### 2.1 Step 1 — Money math sweep (highest leverage)
|
||||
|
||||
Extract `activeInterestsWhere(portId)` helper. Sweep these call sites:
|
||||
|
||||
- `dashboard.service.ts` (already self-consistent, replace inline `isActiveInterest`)
|
||||
- `client-archive-dossier.service.ts:266-267`
|
||||
- `client-restore.service.ts:189-190, 215`
|
||||
- `client-archive.service.ts:214-215`
|
||||
- `reminders.service.ts:424`
|
||||
- `berths.service.ts:173-174` (recommender feasibility check — verify semantics still match)
|
||||
- `interests.service.ts:1161-1162, 196, 361`
|
||||
- `report-generators.ts:63, 85, 121`
|
||||
|
||||
Then:
|
||||
|
||||
- Pipeline value currency conversion (`dashboard.service.ts:35-47`)
|
||||
- Occupancy: switch analytics timeline to `berths.status = 'sold'` (`analytics.service.ts:195`)
|
||||
- Revenue PDF: two-card layout, weighted forecast + won-gross side-by-side (`report-generators.ts:109-150`)
|
||||
|
||||
Estimated effort: ~half day. Single coherent commit set tagged `feat(reporting): canonical active-interest + occupancy + currency-aware pipeline value`.
|
||||
|
||||
### 2.2 Step 2 — Email infrastructure refactor
|
||||
|
||||
- Drop `email_signature_html` setting + admin field (~10 min)
|
||||
- Per-category send-from routing matrix (~3-4h)
|
||||
- Bounce monitoring infrastructure (~6-8h): `email_bounces` table migration, IMAP poller worker, DSN parser, in-app notification on bounce, admin UI for sender configuration
|
||||
- Attachment threshold compose banner + threshold default audit (~1h)
|
||||
|
||||
Estimated effort: ~1 day. Multi-commit.
|
||||
|
||||
### 2.3 Step 3 — Schema additions
|
||||
|
||||
Single migration + service work:
|
||||
|
||||
- `0065_pre_deploy_schema.sql`: `berths.archived_at`, `clients.metadata` (already JSONB — convention update only), `email_bounces` table.
|
||||
- Services + admin UI for archive berth + filter on public feed.
|
||||
|
||||
Estimated effort: ~2h.
|
||||
|
||||
### 2.4 Step 4 — UX features
|
||||
|
||||
- Externally-signed mark (contract + reservation tabs) + audit log + UI indicator
|
||||
- Contract + reservation paper-upload endpoints (clone EOI flow)
|
||||
- Inquiry P-4.5 wire-up (prefill form + persist inquiry_id)
|
||||
- Quick brochure/berth-PDF download buttons (3 surfaces)
|
||||
- Per-user reminder digest schedule
|
||||
- Documents tab N+1 batch query fix
|
||||
|
||||
Estimated effort: ~1 day. Multi-commit.
|
||||
|
||||
### 2.5 Step 5 — Bulk-berth wizard
|
||||
|
||||
Dedicated commit. New `/admin/berths/bulk-add` route + 2-step wizard component + smart-helpers (apply-to-selected, drag-fill). ~half day.
|
||||
|
||||
### 2.6 Step 6 — Investor dashboard charts
|
||||
|
||||
Four toggleable widgets, each its own commit. ~1 day total. Depends on Step 1 (currency converter) and Step 3 (inquiry linkage).
|
||||
|
||||
### 2.7 Step 7 — Terminology sweep
|
||||
|
||||
Mechanical. Run last to minimize merge churn. ~2h.
|
||||
|
||||
### 2.8 Step 8 — Portal token fragment switch
|
||||
|
||||
Dedicated commit. Email template URL builder, page-side fragment readers, Better Auth integration test. ~1h.
|
||||
|
||||
### 2.9 Step 9 — NocoDB inspection complete: simulator DEFERRED
|
||||
|
||||
NocoDB `Interests` carries only the current `Sales Process Level`
|
||||
single-select + a handful of point-in-time event timestamps
|
||||
(`EOI Time Sent`, `Time LOI Sent`, `clientSignTime`,
|
||||
`developerSignTime`, `EOI_Completed_At`, `finalized_document_sent_at`)
|
||||
scattered as text fields. There is **no dedicated stage-change
|
||||
history table** — only the most recent stage value survives.
|
||||
|
||||
The recommender simulator's tier-ladder + heat-score logic depends on
|
||||
"how long did this deal sit at each stage" and "which stage did past
|
||||
deals make it furthest to before falling through." Without an
|
||||
advancement timeline that's not recoverable: every imported interest
|
||||
collapses to one data point.
|
||||
|
||||
**Decision (2026-05-14):** defer the simulator until production
|
||||
accumulates ~10+ won deals under the new pipeline — then the simulator
|
||||
can replay against real CRM history. The existing per-port heat-weight
|
||||
tuning UI in `/admin/berth-recommender` is sufficient for v1 launch.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deferred items (will not block deploy)
|
||||
|
||||
### 3.1 External / operator actions (your side)
|
||||
|
||||
- **Coordinate website cutover env vars**: generate shared secret with `openssl rand -hex 32`, set `CRM_INTAKE_SECRET` on the website and `WEBSITE_INTAKE_SECRET` on the CRM, wire website's berth-map fetch + inquiry-submit + health probe per `docs/website-cutover-runbook.md`.
|
||||
- **Legal review of right-to-be-forgotten scope** — anonymize vs true-delete decision. Mechanical fix once policy is set.
|
||||
- **Documenso v2 endpoint audit against live v2 instance** — verify `/api/v2/envelope/delete` shape, webhook payload (`documentId` vs `id`), `recipientId` vs `token`. Needs a live v2 instance.
|
||||
|
||||
### 3.2 Deferred indefinitely (no current trigger)
|
||||
|
||||
- Bulk import queue worker (`src/lib/queue/workers/import.ts`) — superseded by bespoke migration scripts. Delete placeholder when the comprehensive NocoDB migration ships.
|
||||
- Auto-calibration of berth-recommender weights — depends on accumulating ≥10 won deals in the new system before it produces meaningful results.
|
||||
|
||||
### 3.3 Comprehensive NocoDB → CRM migration
|
||||
|
||||
**Separate workstream** — its own multi-session project. Scope:
|
||||
|
||||
1. Pull every row from legacy NocoDB via MCP.
|
||||
2. Audit messy MinIO storage; tie loose signed PDFs to client/interest/yacht where ownership is recoverable.
|
||||
3. Carry over historical Documenso documents (per-port API key + envelope IDs).
|
||||
4. Map legacy schema → current schema; fill obvious data gaps where the right answer is unambiguous.
|
||||
5. Dry-run + apply against prod DB at initial startup.
|
||||
|
||||
Not on the pre-deploy checklist below — handled as a dedicated planning session before the first port-data import.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pre-deploy operator checklist
|
||||
|
||||
In rough order. Tick as completed.
|
||||
|
||||
### 4.1 External (operator side)
|
||||
|
||||
- [ ] Generate `WEBSITE_INTAKE_SECRET` via `openssl rand -hex 32`; configure both CRM and website to use it.
|
||||
- [ ] Coordinate website-cutover plan with website repo per `docs/website-cutover-runbook.md`.
|
||||
- [ ] Provision IMAP credentials for `noreply@portnimara.com` (and `sales@portnimara.com` if applicable) so bounce monitoring works at boot.
|
||||
- [ ] Provision SMTP credentials for both sender addresses; verify each can actually send.
|
||||
- [ ] DNS + SSL for the CRM domain.
|
||||
- [ ] Decide RTBF policy (anonymize vs true-delete) with legal; document in `docs/runbooks/`.
|
||||
|
||||
### 4.2 CRM side (run after code work is complete)
|
||||
|
||||
- [ ] `pnpm exec vitest run` — all pass.
|
||||
- [ ] `pnpm exec tsc --noEmit` — clean.
|
||||
- [ ] `pnpm exec eslint .` — clean.
|
||||
- [ ] `pnpm exec playwright test --project=smoke` — passes.
|
||||
- [ ] `pnpm db:migrate` against a fresh prod-shaped DB — runner ships in commit `544b129`; verify it actually runs `CREATE INDEX CONCURRENTLY` statements.
|
||||
- [ ] `pnpm tsx scripts/migrate-storage.ts` if switching from filesystem → s3 storage backend.
|
||||
- [ ] Verify `MULTI_NODE_DEPLOYMENT=true` is set if web + worker run on separate nodes (filesystem backend refuses to start otherwise).
|
||||
- [ ] Confirm `EMAIL_REDIRECT_TO` is **unset** in production (`src/lib/env.ts:110` refuses to start otherwise).
|
||||
- [ ] Confirm `DOCUMENSO_API_URL` is bare host (no `/api/v1` suffix) and matches the live Documenso version's `DOCUMENSO_API_VERSION`.
|
||||
- [ ] Verify `/api/public/health?X-Intake-Secret=...` returns 200 with `checks: { db: 'ok', redis: 'ok' }`.
|
||||
|
||||
---
|
||||
|
||||
## 5. What's NOT in this plan
|
||||
|
||||
Items explicitly out of scope for this deploy:
|
||||
|
||||
- IMAP-based two-way email sync — feature scope decision, anti-automation stance.
|
||||
- AI features (semantic search, auto-summarize, anomaly detection) — anti-automation stance.
|
||||
- `.toLocale*` → `formatDate()` sweep (93 sites) — opportunistic as files are touched.
|
||||
- `drizzle-zod` adoption for the remaining ~28 validators — opportunistic.
|
||||
- Reports system + admin-composable report templates (`audit-followups Wave 11.C`) — post-deploy feature work.
|
||||
- Manual client form expansion (`Wave 11.A`) — post-deploy feature work.
|
||||
- Inquiry triage auto-classification (`Wave 11.F`) — post-deploy feature work.
|
||||
- Per-port email branding admin UI (`Wave 11.G`) — post-deploy feature work.
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-05-14._
|
||||
@@ -208,13 +208,42 @@ add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
- Contract + Reservation tab UI shells with paper-signed upload + "send for signing" placeholder
|
||||
- Stage-conditional tab visibility for EOI / Contract / Reservation
|
||||
|
||||
**Deferred (separate sessions):**
|
||||
**Landed in Phase 2-4 (2026-05-13):**
|
||||
|
||||
- Custom document upload-to-Documenso service for contract/reservation (POST PDF → place fields → send). The tabs currently surface a "coming soon" dialog.
|
||||
- Recipient + signing order configurator UI (rep specifies signers per deal for custom-uploaded docs).
|
||||
- Drag-and-drop field placement UI on uploaded PDF previews. The fallback when this lands will be `computeDefaultSignatureLayout()` (footer-anchored fields).
|
||||
- Webhook handler enhancements to track per-signer `sent_at`/`opened_at`/`signed_at` and trigger the cascading "your turn" branded emails. Currently the webhook just updates document status.
|
||||
- Auto-store signed PDFs in storage backend and trigger `sendSigningCompleted()` on `DOCUMENT_COMPLETED`. Old system has this; needs porting.
|
||||
- **Phase 2** — Webhook cascade + on-completion PDF distribution. `handleRecipientSigned` now finds the next pending signer and fires `sendSigningInvitation`; `handleDocumentCompleted` calls `sendSigningCompleted` to all recipients with the signed PDF attached (resolved via `getStorageBackend()` so MinIO + filesystem backends both work). Recipient matching prefers the Documenso recipient `token` captured at send-time (`document_signers.signing_token`); falls back to email match.
|
||||
- **Phase 3** — `lib/services/custom-document-upload.service.ts` + `POST /api/v1/interests/[id]/upload-for-signing`. Magic-byte verifies the PDF, stores via `getStorageBackend`, inserts the `documents` row, runs the full Documenso round-trip (`createDocument → sendDocument → placeFields`), captures recipient tokens, auto-sends invitation when port `sendMode === 'auto'`.
|
||||
- **Phase 4** — `<UploadForSigningDialog>` (`src/components/documents/upload-for-signing-dialog.tsx`). Three-step state machine (file → recipients → fields). Auto-detect runs server-side via `lib/services/document-field-detector.ts` (pdfjs text-extraction + anchor patterns); rep can drag/place/delete fields via native DOM events. Wired into the Contract + Reservation tabs.
|
||||
- **Phase 7** — Project Director RBAC binding. Admin UI exposes `documenso_developer_user_id` / `approver_user_id` / `_label` settings; webhook cascade fires an in-CRM `document_signing_your_turn` notification for linked users alongside the email.
|
||||
|
||||
**Phase 5 — Embedded signing URL emission verification:**
|
||||
|
||||
- `transformSigningUrl()` validated via 10 unit tests in `tests/unit/services/document-signing-urls.test.ts`. Maps signer-role → URL segment as:
|
||||
- `client → /sign/client/<token>`
|
||||
- `developer → /sign/developer/<token>`
|
||||
- `approver → /sign/cc/<token>` — funnels through the CC page with passive copy
|
||||
- `witness → /sign/witness/<token>` — website must handle this segment
|
||||
- `other → /sign/cc/<token>` — same as approver
|
||||
- Hardened to reject malformed source URLs: the function now uses `extractSigningToken()` (rejects tails <8 chars or with non-URL-safe punctuation), so a bare `https://sig.example.com` is returned untouched rather than producing the malformed `<host>/sign/<role>/sig.example.com`.
|
||||
|
||||
**Phase 5 — coordination on the marketing-website side (NOT in this repo):**
|
||||
|
||||
These are tracked here so the CRM stays the source of truth on the contract — the actual edits land in the website repo.
|
||||
|
||||
1. **Website `/sign/[type]/[token].vue` must handle `type ∈ {client, cc, developer, witness}`.** The CRM emits `cc` for both `approver` and `other` roles, and `witness` for explicit witness signers. Anything else lands on the website's `/sign/error` fallback.
|
||||
2. **`signerMessages` map must be keyed on `(documentType, role)`** so a contract recipient hitting `/sign/client/<token>` sees "Sign Your Sales Contract" rather than the EOI default. Until the website is updated, the URL emits `(role, token)` only; the website can resolve documentType from the Documenso embed payload.
|
||||
3. **Post-sign callback** — the legacy portal POSTed to `client-portal.portnimara.com/api/webhook/document-signed`. The CRM no longer needs this — the Documenso webhook at `/api/webhooks/documenso` handles all state updates server-side. The website's POST is now optional; if it's still in place, point it at the CRM's webhook receiver as a real-time UI signal.
|
||||
4. **Apply the nginx CORS block above** on the prod Documenso instance.
|
||||
|
||||
**Genuinely deferred (Phase 6 polish):**
|
||||
|
||||
- Auto-send delay (`eoi_send_delay_minutes` per-port setting + scheduled BullMQ job).
|
||||
- Document expiration toggle (`documents.expires_at` + Documenso `expiresAt` passthrough).
|
||||
- Per-document custom invitation message (textarea on the upload dialog → `documents.invitation_message`).
|
||||
- Reminder rate-limit display ("next reminder available in X days" badge on each unsigned signer in the signing-progress UI).
|
||||
- Failed-webhook recovery admin surface — the BullMQ webhook DLQ exists; needs an admin page with a Replay button.
|
||||
- Per-field metadata side panel for DROPDOWN/RADIO option lists in the Phase 4 dialog.
|
||||
- Pinch-zoom + zoom-out controls on the field-placement canvas.
|
||||
- Recipient drag-reorder via dnd-kit (current UI uses an order number input).
|
||||
|
||||
**Manual ops work for you:**
|
||||
|
||||
|
||||
489
docs/superpowers/audits/2026-05-11-prod-readiness-audit.md
Normal file
489
docs/superpowers/audits/2026-05-11-prod-readiness-audit.md
Normal file
@@ -0,0 +1,489 @@
|
||||
# Prod-Readiness Audit — feat/documents-folders
|
||||
|
||||
**Date:** 2026-05-11
|
||||
**Branch:** `feat/documents-folders` (67 commits ahead of `main`; 34 from this session's documents-hub-split work + 33 from Wave 11.B)
|
||||
**Scope:** 17 parallel domain audits (data-structure & sales-process completeness appended at bottom)
|
||||
**Test posture at audit time:** 1287/1287 unit + integration pass. TypeScript clean (4 pre-existing errors: 1 stale `.next/` build artifact, 3 in a Wave 11.B-era `InMemoryBackend` test stub).
|
||||
|
||||
## Headline
|
||||
|
||||
**~28 Critical, ~38 Important, ~36 Minor findings across 17 domains.** (Original 16-domain count was 23/32/30; Audit 17 added 5/6/6.)
|
||||
|
||||
A handful of the Criticals are real bugs in this session's work that need to be fixed on this branch before merging to `main`. A few are long-standing gaps that survived multiple iterations (storage migration script, `.env.example` URL) and should be fixed independently of this branch but before any prod cutover. Several are mobile/a11y issues that were never going to be caught without a running dev server, which the implementation pass didn't have.
|
||||
|
||||
**Recommendation:** fix the 23 Criticals before merging this branch. Triage Importants into "fix-before-prod" vs "follow-up-on-main". Minors → backlog.
|
||||
|
||||
Estimated effort to clear Criticals: 6-10 hours of focused work.
|
||||
|
||||
---
|
||||
|
||||
## Critical findings
|
||||
|
||||
Grouped by remediation domain. Each entry: brief rationale + file:line ref + fix sketch.
|
||||
|
||||
### A. Core feature regressions in this session's work
|
||||
|
||||
**A1. `handleDocumentCompleted` is not idempotent — Documenso retries duplicate `files` rows + orphan blobs**
|
||||
`src/lib/services/documents.service.ts:1115`
|
||||
|
||||
`resolveWebhookDocument` returns the doc regardless of `status`. Two webhook deliveries (Documenso retries on 5xx) can both pass through and both insert `files` rows; the second `UPDATE documents SET signedFileId` clobbers the first and the first blob is permanently orphaned in storage with no DB row.
|
||||
|
||||
**Fix:** `if (doc.status === 'completed' && doc.signedFileId) return;` immediately after `resolveWebhookDocument`. Standard idempotency gate for this pattern.
|
||||
|
||||
**A2. Realtime hookup dropped by hub rebuild — multi-rep stale data**
|
||||
`src/components/documents/hub-root-view.tsx`, `src/components/documents/entity-folder-view.tsx`
|
||||
|
||||
The pre-rebuild hub consumed `document:*` and `file:*` Socket.IO events via `useRealtimeInvalidation`. After the rebuild, both `HubRootView` and `EntityFolderView` have no realtime subscription at all. The remaining hook lives inside `FlatFolderListing`, which is torn down when navigating away. Result: rep A on `Clients/Smith/` will not see rep B's upload until manual refresh; webhook-completed signatures don't appear in the Signing-in-progress section.
|
||||
|
||||
**Fix:** lift `useRealtimeInvalidation` up to `DocumentsHub` with both `document:*` and `file:*` events targeting the prefix keys `['files']` and `['documents']`. TanStack Query prefix matching will invalidate the aggregated keys.
|
||||
|
||||
**A3. LEFT JOIN port_id in ON clause defeats `idx_docs_signed_file_id`**
|
||||
`src/lib/services/files.ts:544`
|
||||
|
||||
```sql
|
||||
LEFT JOIN documents d ON d.signed_file_id = f.id AND d.port_id = $portId
|
||||
```
|
||||
|
||||
Planner picks `idx_docs_port` and applies `signed_file_id = f.id` as a residual filter. At scale this is 20 × N comparisons per page load instead of 20 point lookups. Same pattern in `documents.service.ts:1915` for the workflow projection.
|
||||
|
||||
**Fix:** drop `AND d.port_id = portId` from the ON clause and add `AND (d.port_id = portId OR d.id IS NULL)` to the outer WHERE. Or add a composite `(signed_file_id, port_id)` index. `files.port_id` is already scoped, so cross-port leak risk is zero.
|
||||
|
||||
**A4. Importer doesn't set `files.folder_id` — imported files invisible to folder queries**
|
||||
`scripts/import-organized-documents.ts:196-208`
|
||||
|
||||
The `documents` row gets `folderId` correctly (line 216) but the companion `files` row does not. `files.folder_id` is a separate column. The backfill won't rescue these — it only acts on files with entity FKs set, and the importer sets none of those either.
|
||||
|
||||
**Fix:** copy `folderId` into the `files.values(...)` block alongside the document insert.
|
||||
|
||||
**A5. `chk_system_folder_shape` has NULL escape — corrupted system rows persist**
|
||||
`src/lib/db/migrations/0051_documents_hub_split.sql:22-28`
|
||||
|
||||
`NOT system_managed OR entity_type = 'root' OR (...)` evaluates to `NULL` (not `false`) when `entity_type IS NULL` and `system_managed = true`. Postgres treats NULL as "not false" so the constraint passes. Confirmed by direct insert test.
|
||||
|
||||
**Fix:** add `entity_type IS NOT NULL` to the constraint, or restructure as `CHECK (NOT system_managed OR (entity_type IS NOT NULL AND (entity_type = 'root' OR (entity_type = ANY(...) AND entity_id IS NOT NULL))))`.
|
||||
|
||||
**A6. `document-folders.service.ts` has zero log lines — silent failures across the entire folder service**
|
||||
`src/lib/services/document-folders.service.ts` (no `logger` import)
|
||||
|
||||
Orphan rows in `listTree` are silently dropped (line 83-84). The 50-attempt suffix-loop exhaustion throws `ConflictError` with no log. `ensureSystemRoots` "missing root after upsert" throws raw `Error`. At 3am you would have no diagnostic for folder-related failures.
|
||||
|
||||
**Fix:** `import { logger } from '@/lib/logger'`. Add `logger.warn` on orphan-detection, retry-exhaustion (both `ensureEntityFolder` and `syncEntityFolderName`), and the missing-root invariant in `ensureSystemRoots`.
|
||||
|
||||
**A7. `demoteSystemFolderOnEntityDelete` is not wired into `client-hard-delete.service.ts`**
|
||||
`src/lib/services/document-folders.service.ts:650` (exported but zero callers)
|
||||
|
||||
`client-hard-delete.service.ts` exists. It clears entity FKs on `files` and `documents` inside its transaction but never demotes the system folder. After hard-delete: folder retains `system_managed=true` + the dead `entity_id`. The partial unique index `uniq_document_folders_entity` permanently blocks any future client folder that would get the same display name. Also a GDPR right-to-be-forgotten gap.
|
||||
|
||||
**Fix:** call `demoteSystemFolderOnEntityDelete(portId, 'client', clientId)` inside `hardDeleteClient`'s transaction (or as a post-commit hook with audit log). Confirm whether `companies`/`yachts` have analogous hard-delete services that also need wiring.
|
||||
|
||||
### B. Accessibility blockers (WCAG 2.1 AA failures)
|
||||
|
||||
**B1. Unlabeled search input**
|
||||
`src/components/documents/documents-hub.tsx:265`
|
||||
|
||||
`<Input placeholder="Search by title..." />` — placeholder is not a label. Fails WCAG 1.3.1 / 4.1.2.
|
||||
**Fix:** `aria-label="Search documents by title"`.
|
||||
|
||||
**B2. No `aria-pressed` on type-filter chips**
|
||||
`src/components/documents/documents-hub.tsx:276-299`
|
||||
|
||||
Active state is purely visual. Screen readers can't tell which chip is selected. Fails WCAG 4.1.2.
|
||||
**Fix:** `aria-pressed={typeFilter === t}` on each chip.
|
||||
|
||||
**B3. No `aria-expanded` on tree chevrons; folder-row labels lack context**
|
||||
`src/components/documents/folder-tree-sidebar.tsx:125, 135-155`
|
||||
|
||||
The expand button has `aria-label="Collapse"` / `"Expand"` with no folder name, so SR users hear "Expand button, Expand button…" with no differentiation. And it lacks `aria-expanded` so the open/closed state is invisible.
|
||||
**Fix:** `aria-expanded={open}`, `aria-label={\`${open ? 'Collapse' : 'Expand'} ${node.name}\`}`. Same pattern in `documents-hub.tsx:210-217` for the per-row signer expand.
|
||||
|
||||
**B4. `aria-label` on Lock SVG becomes part of button's accessible name**
|
||||
`src/components/documents/folder-tree-sidebar.tsx:150-154`
|
||||
|
||||
`<Lock aria-label="System folder" />` inside the folder-select `<button>` produces accessible name "Smith System folder" rather than a separate badge announcement.
|
||||
**Fix:** `aria-hidden="true"` on the SVG + `<span className="sr-only"> (system folder)</span>` after the folder name.
|
||||
|
||||
### C. Mobile blockers
|
||||
|
||||
**C1. FolderTreeSidebar stacks above main panel with no collapse toggle**
|
||||
`src/components/documents/folder-tree-sidebar.tsx:32` — `w-full sm:w-60`
|
||||
|
||||
On mobile the entire folder tree renders above the document list. With any non-trivial tree, reps scroll past it to reach content. Every other secondary-nav page uses a Sheet or Collapsible.
|
||||
**Fix:** wrap in a Sheet drawer (default closed on mobile) with a "Show folders" trigger button.
|
||||
|
||||
**C2. `border-r` on wrong axis at mobile breakpoint**
|
||||
`src/components/documents/folder-tree-sidebar.tsx:32`
|
||||
|
||||
Right border draws on full-width-stacked element instead of bottom separator.
|
||||
**Fix:** `border-b sm:border-r border-r-0`.
|
||||
|
||||
**C3-C7. 5 tap-target violations below WCAG 44×44px minimum**
|
||||
|
||||
- C3: chevron expand button (`folder-tree-sidebar.tsx:125`) — 20×20px
|
||||
- C4: row expand chevron (`documents-hub.tsx:210-216`) — no sizing
|
||||
- C5: "view signing details" (`entity-folder-view.tsx:82-89`) — ~20px tall
|
||||
- C6: "Show all (N)" (`aggregated-section.tsx:101-108`) — ~18px tall
|
||||
- C7: type-filter chips (`documents-hub.tsx:277-297`) — `py-0.5` gives ~24px
|
||||
|
||||
**Fix:** `min-h-[44px]` + `py-2` (or `py-1.5`) on each. Or wrap in `<Button size="sm">` where the visual change is acceptable.
|
||||
|
||||
### D. Long-standing infra gaps (independent of this branch, must fix before prod)
|
||||
|
||||
**D1. `migrate-storage.ts` migrates zero files — silent footgun**
|
||||
`src/lib/storage/migrate.ts:40-43`
|
||||
|
||||
`TABLES_WITH_STORAGE_KEYS` is an empty array. The comment says "Phase 6a ships an empty list" — never followed up. Running `pnpm tsx scripts/migrate-storage.ts` flips the active backend but migrates nothing. Existing blobs in `files`, `berth_pdf_versions`, `brochure_versions`, `gdpr_exports`, `report_snapshots` become unreachable.
|
||||
|
||||
**Fix:** populate the table list with all five tables + their `storagePath`/`storageKey` columns. The `copyAndVerify` SHA-256 round-trip already works; it just needs entries to act on.
|
||||
|
||||
**D2. `.env.example` DOCUMENSO_API_URL has `/api/v1` baked in → double-path URLs**
|
||||
`.env.example`
|
||||
|
||||
Current value: `DOCUMENSO_API_URL=https://documenso.example.com/api/v1`. The client appends `/api/v1/documents` etc., producing `https://documenso.example.com/api/v1/api/v1/documents`. Anyone copying the example file gets 404s from Documenso with no diagnostic. Applies to both v1 and v2 deployments.
|
||||
|
||||
**Fix:** change to `DOCUMENSO_API_URL=https://documenso.example.com` (bare host). Update the admin UI placeholder to match.
|
||||
|
||||
### E. Test theatre — assertions never run
|
||||
|
||||
**E1. Smoke spec `test.skip()` guards mask infrastructure failures**
|
||||
`tests/e2e/smoke/04-documents-hub-aggregated.spec.ts:99-104`
|
||||
`tests/e2e/smoke/04-documents-hub-upload-into-entity.spec.ts:41, 129, 153, 165`
|
||||
|
||||
When the API setup step (client create, file upload, file list) returns non-2xx, the test calls `test.skip(true, ...)` and proceeds no further. Playwright reports skipped tests as passed — a green CI run hides whether the actual assertion would have succeeded.
|
||||
|
||||
**Fix:** convert skip-on-non-ok to `expect.fail()` so a 401 on setup becomes a real test failure. Skip should only fire when the precondition is genuinely "this scenario doesn't apply", not "the infrastructure broke".
|
||||
|
||||
### F. Webhook event coverage gap (with v1 + v2 support in scope)
|
||||
|
||||
**F1. `DOCUMENT_DECLINED` has no handler**
|
||||
`src/app/api/webhooks/documenso/route.ts:146-214`
|
||||
|
||||
v2 distinguishes Decline (recipient refuses) from Reject (admin cancels). The switch handles `DOCUMENT_REJECTED` only. A v2-declined document leaves the CRM document in `sent` status indefinitely; the poller doesn't catch it either (only checks `COMPLETED` and `EXPIRED`).
|
||||
|
||||
**Fix:** add a `DOCUMENT_DECLINED` case to the switch. Behaviorally mirror `DOCUMENT_REJECTED` initially; product can refine if Decline vs Reject should differentiate downstream.
|
||||
|
||||
---
|
||||
|
||||
## Important findings (fix before prod, or as follow-up on `main`)
|
||||
|
||||
Listed by audit domain. Each has a file:line ref in its source audit; I'll quote the highlights here for triage.
|
||||
|
||||
### Security
|
||||
|
||||
- **`storagePath` + `storageBucket` exposed via aggregated files API** (`files.ts:533-534`) — internal storage paths reach authenticated rep clients via `GET /api/v1/files?entityType=X`. Auditors flagged this from both Security and Integration angles. Sanitize at service layer.
|
||||
- **Missing `portId` on UPDATE in folder-move route** (`api/v1/documents/[id]/folder/route.ts:41-44`) — pre-flight read scopes by portId so no current exploit, but defense-in-depth gap that breaks if pre-flight is ever refactored.
|
||||
- **Signer emails exposed to all `documents.view` holders** — confirm with product whether read-only roles should see signatory email addresses or get them redacted.
|
||||
|
||||
### Database / Migration
|
||||
|
||||
- **`uniq_document_folders_entity` doesn't cover `entity_type = NULL`** — rows with NULL entity_type but non-NULL entity_id can duplicate. Closes when CHECK constraint is tightened (A5 above).
|
||||
- **Backfill transaction holds advisory lock across N `ensureEntityFolder` calls** — at 10k files the lock is held for minutes. Batch in chunks of 500.
|
||||
- **`CREATE INDEX` without `CONCURRENTLY`** in migration 0051 — blocks writes briefly. Quantify: short-duration on small tables, moderate on prod-sized. Split for zero-downtime if needed.
|
||||
|
||||
### Concurrency / Error Paths
|
||||
|
||||
- **Storage blob orphaned on DB-insert failure** in `handleDocumentCompleted` — `storage.put` before `db.insert(files)`. No janitor. Long-standing tradeoff; document explicitly.
|
||||
- **`ensureSystemRoots`/`ensureEntityFolder` outside backfill transaction** — folder rows persist if the wrapping tx rolls back. Idempotent so re-run heals.
|
||||
- **`syncEntityFolderName` 50-attempt cap with concurrent renames to same target** — silent log + stale folder name. Accepted divergence.
|
||||
|
||||
### Performance
|
||||
|
||||
- **N+1 grows with linked entities** — leasing company with 50 yachts = 110 queries per page load. Worst case (5 companies + 100 yachts) = 216. Acceptable for now; future optimization: single CTE with grouping.
|
||||
- **Count queries can collapse via window function** — `count(*) OVER ()` halves round-trip count at scale.
|
||||
- **Missing composite indexes `(port_id, client_id)` / `(port_id, company_id)` / `(port_id, yacht_id)` on `files`** — same for `documents`. Add before prod backfill at scale.
|
||||
- **`listDocuments` calls `listTree()` twice when `includeDescendants=true`** — pass already-fetched tree into `hydrateDocumentsWithDownloadUrl`.
|
||||
|
||||
### Data migration (importer)
|
||||
|
||||
- **System-root collision risk** — bucket folders named `Clients`/`Companies`/`Yachts` silently merge into auto-created system roots. Add a pre-flight check that warns when any top-level segment matches a system root name.
|
||||
|
||||
### Observability
|
||||
|
||||
- **Archive/restore hooks missing `portId` in log context** (`companies.service.ts:215`, `yachts.service.ts:193`) — clients has it; companies and yachts don't.
|
||||
- **Backfill CLI has no row-count telemetry** — only "Backfill complete" on success. Want files-processed / folders-created / FKs-propagated counts.
|
||||
- **No log on empty aggregated projection** — `assertEntityInPort` returning false produces a silent empty result. Log warn with `portId + entityType + entityId`.
|
||||
- **`handleDocumentCompleted` outer catch loses `portId`** (line 1197).
|
||||
|
||||
### UI/UX
|
||||
|
||||
- **Em-dash in `SigningDetailsDialog` description** (line 62) — user-facing copy.
|
||||
- **Em-dashes baked into aggregated group labels** (`FROM COMPANY — ACME CORP`) — rendered on every entity folder view. `files.ts:335`, `documents.service.ts:1877`. Replace with colon or slash.
|
||||
- **Mixed `Loading...` (ASCII) and `Loading…` (Unicode ellipsis)** across components. Normalize.
|
||||
- **Raw `partially_signed` status in `HubRootView`** — no StatusPill or underscore replacement. Apply `StatusPill` or at minimum `replace(/_/g, ' ')`.
|
||||
- **"view signing details" button too subtle** — inline-text in a tight muted cluster, blends into the date. Consider `<Button variant="ghost" size="sm">`.
|
||||
|
||||
### Integration conformance (with v1 + v2 support)
|
||||
|
||||
- **Documenso poll worker double-fire of `handleDocumentCompleted`** writes a second blob + second `files` row and overwrites `signedFileId`. Confirmed by both concurrency and integration audits. Resolved by A1's idempotency gate.
|
||||
- **Poll worker omits `portId`** when calling `handleRecipientSigned` / `handleDocumentCompleted` — multi-port correctness risk.
|
||||
- **MinIO operations have no socket timeout** — TCP blackhole stalls workers indefinitely. `fetchWithTimeout` doesn't cover the minio client's `putObject`/`getObject`. Wrap with an external timeout (`AbortController` or `Promise.race`).
|
||||
- **No 0-byte check on `downloadSignedPdf` result** — a 0-byte response from Documenso writes a permanent corrupt `signedFileId` with no recovery path.
|
||||
- **`DOCUMENSO_API_VERSION` env defaults to `v1`** with no documentation in `.env.example` that v2 is supported. A v2-pointed deployment that misses the env var fires v1 code paths against a v2 instance.
|
||||
- **`DOCUMENT_DECLINED` event handler** — already listed as Critical F1; mentioned again here because the integration audit captured it under v2-specific gaps.
|
||||
- **`RECIPIENT_VIEWED` / `RECIPIENT_SIGNED`** v2 event aliases — currently silently dropped. Confirm whether v2 actually fires these or maps to `DOCUMENT_OPENED` / `DOCUMENT_SIGNED` like v1. If v2 fires them, add handlers.
|
||||
|
||||
### Realtime / Socket.IO
|
||||
|
||||
- **`useRealtimeInvalidation` is inside `FlatFolderListing`, not `DocumentsHub`** — torn down when navigating away. Lifting to DocumentsHub closes this and unblocks A2 cleanly.
|
||||
- **`['document-folders']` query key has no realtime invalidation path** — rep B renaming a folder takes up to 30s `staleTime` to surface for rep A. Add a folder-rename socket emit + invalidate.
|
||||
|
||||
### Audit log completeness
|
||||
|
||||
- **`createFolder` has no audit log** (line 102-136) — inconsistent with rename/move/delete which all audit.
|
||||
- **`handleDocumentCompleted` file insert has no audit** (line 1163-1180) — signed PDFs created with no audit trail.
|
||||
- **`syncEntityFolderName` ignores `_userId`** — folder renames driven by entity rename leave no audit trail.
|
||||
- **Archive/restore suffix helpers no audit** — parent entity action audits, but folder mutation doesn't.
|
||||
|
||||
### Type-safety
|
||||
|
||||
- **`entityType as 'client'|'company'|'yacht'`** in `documents-hub.tsx:134` — no runtime guard. Fix with `ENTITY_TYPES.has()`.
|
||||
- **`INFLIGHT_STATUSES as unknown as string[]`** — replace with `[...INFLIGHT_STATUSES]`.
|
||||
- **Loose `files?/workflows?` union + unconstrained `T`** in `AggregatedSection` — refactor to discriminated union + `T extends { id: string }`.
|
||||
|
||||
### Test quality
|
||||
|
||||
- **`mapWorkflowStatus` `partially_signed` fix has no regression test**.
|
||||
- **`applyEntityRestoredSuffix` "restore without prior archive" path not tested**.
|
||||
- **`folderId="" → null` validator transform has zero test coverage**.
|
||||
- **`syncEntityFolderName` collision beyond `(2)` untested** — if `isSiblingNameConflict` ever mis-classifies the error shape, retries never fire and the test wouldn't notice.
|
||||
|
||||
### Mobile
|
||||
|
||||
- **DocumentsHub sets no `useMobileChrome`/`setChrome` title** — falls back to URL-segment title-casing.
|
||||
- **FolderActionsMenu trigger overrides to 28×28px** — should use default `size="icon"` (44×44).
|
||||
- **SigningDetailsDialog signer email no `truncate`** — long emails overflow on narrow viewports.
|
||||
- **Breadcrumb tap targets too small** (`folder-breadcrumb.tsx:41-60`) — no padding.
|
||||
|
||||
---
|
||||
|
||||
## Minor (backlog)
|
||||
|
||||
Approximately 30 minor findings across all domains. Highlights:
|
||||
|
||||
- **Em-dashes in `CLAUDE.md`** (29 in prose bullets, all in pre-existing content; no new em-dashes added in commit `ab79894`) — backlog cleanup pass.
|
||||
- **`@radix-ui/react-icons` unused** — safe to remove from `package.json`.
|
||||
- **`@hookform/resolvers`, `zod`, `tailwindcss`** all have major-version updates available — DO NOT upgrade pre-cutover (breaking changes).
|
||||
- **Sonnet color contrast on `muted-foreground/70` opacity variant** (`aggregated-section.tsx:94`) — ~3.2:1 fails WCAG AA for normal text. Drop the `/70` tint.
|
||||
- **`<header>` element inside `<div>` not under a sectioning element** (`aggregated-section.tsx:92`) — wrong landmark scope; use `<div>` or `<h6>`.
|
||||
- **`h3` → `h5` jump in SigningDetailsDialog** (skipped heading level).
|
||||
- **`renameFolder` `updatedAt` test uses 10ms `setTimeout`** — fragile but `toBeGreaterThan` is OK; can drop the sleep entirely.
|
||||
- **`MINIO_AUTO_CREATE_BUCKET`** bypasses zod env schema; undocumented in `.env.example`.
|
||||
- **`DOCUMENSO_TEMPLATE_ID_EOI` + recipient ID vars absent from `.env.example`** with Port-Nimara-specific hardcoded defaults.
|
||||
- **`voidDocument` raw `FetchTimeoutError` propagation** — no `CodedError('DOCUMENSO_TIMEOUT')` wrap. Both call sites handle gracefully; cosmetic.
|
||||
|
||||
---
|
||||
|
||||
## Audit-by-audit completion log
|
||||
|
||||
| # | Audit | Status | Critical | Important | Minor |
|
||||
| --- | ------------------------------------------- | ------ | -------- | --------- | ----- |
|
||||
| 1 | Security & multi-tenant isolation | ✓ | 0 | 3 | 0 |
|
||||
| 2 | Database & migration safety | ✓ | 1 | 3 | 3 |
|
||||
| 3 | Concurrency, idempotency, error paths | ✓ | 1 | 3 | 3 |
|
||||
| 4 | Performance & query plans | ✓ | 1 | 3 | 3 |
|
||||
| 5 | Data migration from old system | ✓ | 1 | 1 | 3 |
|
||||
| 6 | Production observability | ✓ | 2 | 4 | 3 |
|
||||
| 7 | UI/UX | ✓ | 0 | 5 | 4 |
|
||||
| 8 | Integration conformance (Context7) | ✓ | 0 | 0 | 3 |
|
||||
| 9 | Dependency audit | ✓ | 0 | 0 | ~10 |
|
||||
| 10 | Accessibility (WCAG 2.1 AA) | ✓ | 4 | 5 | 4 |
|
||||
| 11 | Test quality & coverage | ✓ | 2 | 6 | 3 |
|
||||
| 12 | Realtime / Socket.IO | ✓ | 3 | 2 | 1 |
|
||||
| 13 | Audit log completeness | ✓ | 0 | 4 | 4 |
|
||||
| 14 | Type-safety | ✓ | 0 | 3 | 3 |
|
||||
| 15 | Mobile / responsive | ✓ | 6 | 5 | 3 |
|
||||
| 16 | Integration holes (MinIO + Documenso) | ✓ | 2 | 5 | 5 |
|
||||
| 17 | Data structure & sales process completeness | ✓ | 5 | 6 | 6 |
|
||||
|
||||
---
|
||||
|
||||
## Suggested remediation order
|
||||
|
||||
**Pre-merge (block this branch):**
|
||||
|
||||
1. A1 (concurrency idempotency) — 1 line, 5 minutes.
|
||||
2. A2 (realtime hookup) — ~30 min: lift one hook up two layers in component tree.
|
||||
3. A4 (importer folder_id) — 1 line in scripts/import-organized-documents.ts.
|
||||
4. A5 (CHECK NULL escape) — 1-line migration patch + re-apply.
|
||||
5. A6 (folder service logger) — add `import { logger }` + 3 warn calls.
|
||||
6. A7 (demote on hard-delete) — 1 line in client-hard-delete.service.ts.
|
||||
7. B1-B4 (a11y) — ~30 min combined: aria attributes only.
|
||||
8. C1-C7 (mobile) — ~1-2 hours: Sheet wrap + tap-target padding.
|
||||
9. E1 (test theatre) — convert skips to fails.
|
||||
10. F1 (DOCUMENT_DECLINED) — add case to switch.
|
||||
|
||||
**Pre-prod cutover (independent of branch):**
|
||||
|
||||
- A3 (LEFT JOIN port_id) — performance fix.
|
||||
- D1 (storage migration table list) — populate TABLES_WITH_STORAGE_KEYS.
|
||||
- D2 (.env.example URL) — strip `/api/v1`.
|
||||
- All Important security findings.
|
||||
- 0-byte signed PDF check.
|
||||
- MinIO socket timeout wrapper.
|
||||
- DOCUMENSO_API_VERSION documentation + v2 event audit.
|
||||
|
||||
**Post-prod (backlog on main):**
|
||||
|
||||
- Important UI/UX (em-dashes, loading state consistency, status pill on HubRootView).
|
||||
- Important audit log completeness.
|
||||
- Important type-safety tightening.
|
||||
- All Minor.
|
||||
|
||||
---
|
||||
|
||||
## Notes on session vs. pre-existing findings
|
||||
|
||||
Several Criticals (D1 storage migration script, D2 .env.example, A3 LEFT JOIN port_id, parts of the audit-log gaps and observability gaps) are long-standing — they survived multiple iterations of the codebase, sometimes since Phase 6a. Fixing them on this branch is fine but they're not regressions introduced by this session.
|
||||
|
||||
The session's actual regressions are: A1 (idempotency), A2 (realtime), A5 (CHECK NULL), A6 (folder service has no logger), A7 (demote not wired), B1-B4 (a11y missed during the UI rebuild), C1-C7 (mobile never tested), E1 (test theatre).
|
||||
|
||||
The dependency, integration-conformance (Context7), and type-safety audits are clean of Critical findings — your dep posture is solid and the implementation follows published specs.
|
||||
|
||||
---
|
||||
|
||||
## Audit 17 — Data structure & sales process completeness
|
||||
|
||||
**5 Critical, 6 Important, 6 Minor.** This audit walked the entire entity graph and the sales-process pipeline end-to-end. Most findings are not regressions from this session — they are gaps in the sales-process plumbing that pre-date the documents-hub-split work but matter for prod cutover. C-1 and C-3 are session-introduced; C-2, C-4, C-5 are long-standing.
|
||||
|
||||
### Critical (data graph + sales pipeline)
|
||||
|
||||
**G-C1. `deleteFolderSoftRescue` re-parents documents but not files — split delete behavior**
|
||||
`src/lib/services/document-folders.service.ts:268-282`
|
||||
|
||||
The soft-rescue transaction `UPDATE`s `documents.folderId = newParent`, then deletes the folder row. The schema cascade on `files.folderId` is `ON DELETE SET NULL` (not `SET DEFAULT newParent`) — so any files in the deleted folder land at **root**, while documents in the same folder correctly land at the deleted folder's **parent**. A folder containing both will scatter on delete.
|
||||
|
||||
Fix: inside the transaction, between the documents UPDATE and the folder DELETE:
|
||||
|
||||
```ts
|
||||
await tx
|
||||
.update(files)
|
||||
.set({ folderId: newParent })
|
||||
.where(and(eq(files.folderId, folderId), eq(files.portId, portId)));
|
||||
```
|
||||
|
||||
**G-C2. Client hard-delete blocked by `scratchpadNotes.linkedClientId` RESTRICT FK**
|
||||
`src/lib/services/client-hard-delete.service.ts:190-218` + `src/lib/db/schema/system.ts:180`
|
||||
|
||||
`scratchpadNotes.linkedClientId references clients.id` with no `onDelete` → defaults to RESTRICT. The hard-delete service nullifies six nullable FKs (files, documents, formSubmissions, emailThreads, reminders, documentSends) but skips `scratchpadNotes`. Any rep who scratchpad-linked a note to a client → hard-delete throws an FK violation and aborts the transaction.
|
||||
|
||||
Fix: add to the nullification block:
|
||||
|
||||
```ts
|
||||
await tx
|
||||
.update(scratchpadNotes)
|
||||
.set({ linkedClientId: null })
|
||||
.where(eq(scratchpadNotes.linkedClientId, args.clientId));
|
||||
```
|
||||
|
||||
**G-C3. Client hard-delete leaves ghost system folder with stale `entityId`**
|
||||
`src/lib/services/client-hard-delete.service.ts:214-218`
|
||||
|
||||
The unique index `uniq_document_folders_entity` on `(portId, entityType, entityId)` enforces a singleton system folder per entity. Hard-delete removes the client row but does not call `demoteSystemFolderOnEntityDelete`. The folder persists with `systemManaged=true, entityType='client', entityId=<deleted-id>` — invisible in the sidebar but holding the unique slot.
|
||||
|
||||
Fix: after the client delete, fire-and-forget the demote:
|
||||
|
||||
```ts
|
||||
void demoteSystemFolderOnEntityDelete(args.portId, 'client', args.clientId).catch(logger.error);
|
||||
```
|
||||
|
||||
(This is the same wire-up A7 in the main report flagged — confirmed missing on the hard-delete pathway specifically.)
|
||||
|
||||
**G-C4. Five of seven berth-rule triggers are defined but never called**
|
||||
`src/lib/services/berth-rules-engine.ts:37-44` vs `src/lib/services/documents.service.ts:798,894,1234`
|
||||
|
||||
`DEFAULT_RULES` defines triggers for `eoi_sent`, `eoi_signed`, `deposit_received`, `contract_signed`, `interest_archived`, `interest_completed`, `berth_unlinked`. Only `eoi_sent` and `eoi_signed` are passed to `evaluateRule` anywhere in the codebase.
|
||||
|
||||
Concrete consequences:
|
||||
|
||||
- Deposit received (invoice paid) → no berth state change. Should auto-mark berth as Sold.
|
||||
- Contract signed → no berth state change.
|
||||
- Interest archived → no "berth available" suggestion fires.
|
||||
- Interest marked Won/Lost → no rule trigger.
|
||||
- Interest unlinked from berth → no rule trigger (off-by-default, but configurable and silently dead).
|
||||
|
||||
Fix sketches:
|
||||
|
||||
- `invoices.ts:741` (after `advanceStageIfBehind('deposit_10pct')`):
|
||||
```ts
|
||||
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
|
||||
void evaluateRule('deposit_received', updated.interestId, portId, meta);
|
||||
```
|
||||
- `interests.service.ts:archiveInterest` after `softDelete`: fetch primary berth via `getPrimaryBerth`, then `void evaluateRule('interest_archived', ...)`.
|
||||
- `interests.service.ts:setInterestOutcome` after the outcome write: `void evaluateRule('interest_completed', ...)`.
|
||||
- `interest-berths.service.ts:removeInterestBerth` after delete: `void evaluateRule('berth_unlinked', ...)`.
|
||||
|
||||
**G-C5. `contract_sent` and `contract_signed` pipeline stages have zero auto-advancement triggers**
|
||||
`src/lib/services/documents.service.ts` (absent)
|
||||
|
||||
`STAGE_TRANSITIONS` defines `contract_sent` and `contract_signed` and they render in the Kanban/funnel UI, but no code path calls `advanceStageIfBehind(..., 'contract_sent')` or `advanceStageIfBehind(..., 'contract_signed')`. Sending a reservation agreement → no stage advance. Completing one (signed PDF arrives, `contractFileId` set in `handleDocumentCompleted` ~line 887) → no stage advance.
|
||||
|
||||
Effect: deals stall at whatever stage they hit when the reservation agreement was sent, until a rep manually drags them in the Kanban.
|
||||
|
||||
Fix: in `documents.service.ts`:
|
||||
|
||||
- `sendDocument` pathway (~line 798): if `doc.documentType === 'reservation_agreement'`, fire `advanceStageIfBehind(..., 'contract_sent', meta, 'Reservation agreement sent')`.
|
||||
- `handleDocumentCompleted` (~line 887, where `contractFileId` is set): fire `advanceStageIfBehind(..., 'contract_signed', meta, 'Reservation agreement signed')` and `evaluateRule('contract_signed', ...)`.
|
||||
|
||||
### Important (cross-entity gaps)
|
||||
|
||||
**G-I1. Portal email uniqueness is global, not per-port**
|
||||
`src/lib/db/schema/portal.ts:40` — `uniqueIndex('idx_portal_users_email_unique').on(table.email)`
|
||||
|
||||
A client who has dealt with two ports under this deployment can only ever have one portal account. The second `createPortalUser` will throw a unique-constraint violation. Make per-port (`.on(table.email, table.portId)`) if multi-port is a real deployment scenario, or document as single-port-only.
|
||||
|
||||
**G-I2. `archiveInterest` skips `interest_archived` rule and `notifyNextInLine`**
|
||||
`src/lib/services/interests.service.ts:985-1014`
|
||||
|
||||
Archive does the audit log + socket emit but does not (a) trigger the berth-availability rule, (b) notify the waiting list for the primary berth. The waiting-list code is only fired when the **client** is archived, not the **interest**.
|
||||
|
||||
Fix after `softDelete`: fetch primary berth → `evaluateRule('interest_archived', ...)` + `notifyNextInLine(primaryBerth.berthId, portId, meta.userId)`.
|
||||
|
||||
**G-I3. Yacht/company `restore` paths missing `applyEntityRestoredSuffix`**
|
||||
`src/lib/services/yachts.service.ts:178` + `src/lib/services/companies.service.ts:200`
|
||||
|
||||
Archive sides call `applyEntityArchivedSuffix`. Restore paths do not exist for yachts/companies at all today — but when they are added (or if the entity-restoration logic moves to the `clients/archive` parity routes), `applyEntityRestoredSuffix` must be wired. `clients.service.ts:596` already does this correctly.
|
||||
|
||||
**G-I4. `berthRecommendations.interestId` has no FK constraint**
|
||||
`src/lib/db/schema/berths.ts:134` — column comment says "references interests.id" but `.references()` is omitted.
|
||||
|
||||
If an interest is hard-deleted (currently only possible via `db:studio` or future migrations), stale `berthRecommendations` rows persist and skew the recommender's tier aggregates. Add `.references(() => interests.id, { onDelete: 'cascade' })` and generate a migration.
|
||||
|
||||
**G-I5. Portal invoices invisible for company-billed deals**
|
||||
`src/lib/services/portal.service.ts:232`
|
||||
|
||||
`getClientInvoices` matches on `billingEmail in client.emails`. Invoices with `billingEntityType='company'` (the most common B2B pattern: client is an individual buying through their company) are not surfaced even when the client is the company's director. Extend the query to OR-in invoices where `billingEntityType='company' AND company.directorClientId = portalUser.clientId`.
|
||||
|
||||
**G-I6. `hub-counts` API endpoint is orphaned**
|
||||
`src/app/api/v1/documents/hub-counts/route.ts:5-10` + `getHubTabCounts` in `documents.service.ts:397`
|
||||
|
||||
The hub rebuild on this branch removed the component that called this endpoint. Service function + route are dead code. Either wire a KPI strip back into `HubRootView` (the spec does call for this) or delete the route + service function.
|
||||
|
||||
### Minor
|
||||
|
||||
- **G-M1.** Website inquiry → client conversion is fully manual; `prefill_*` query params are hints only. `inquiry-inbox.tsx:119`.
|
||||
- **G-M2.** Polymorphic array columns (`photoFileIds`, `attachmentFileIds`) have no FK protection. Files deleted via any future hard-purge path silently orphan these arrays.
|
||||
- **G-M3.** `berthReservations.interestId` RESTRICT default (notNull, no `onDelete`) — intent (preserve history vs oversight) undocumented.
|
||||
- **G-M4.** `setInterestOutcome` to `won` does not fire berth-sold; downstream of G-C4.
|
||||
- **G-M5.** `advanceStageIfBehind` silently no-ops when `yachtId` is null at `open` stage. Walk-in EOIs (vessel not yet identified) stall invisibly at `open`.
|
||||
- **G-M6.** `removeInterestBerth` emits socket + webhook but skips `evaluateRule('berth_unlinked')`. Downstream of G-C4.
|
||||
|
||||
### Impact on cutover gate
|
||||
|
||||
- **G-C2** is the most pressing for cutover: it is a hard error on a foreseeable action (any rep deleting a client with a linked scratchpad note → 500). Fix before any team testing.
|
||||
- **G-C4 + G-C5** mean the berth-map status and Kanban columns will drift visually for every deal that progresses past EOI. This is not data corruption, but it will erode rep trust quickly during initial team testing. Fix before cutover.
|
||||
- **G-C1** is a UX correctness issue; will surprise reps but won't lose data. Same-branch fix.
|
||||
- **G-C3** is data-integrity hygiene; no immediate user-visible effect but pollutes the unique-folder slot. Same-branch fix.
|
||||
|
||||
### Updated headline
|
||||
|
||||
With Audit 17 folded in, the corrected count is **~28 Critical, ~38 Important, ~36 Minor** across 17 domains. The new Criticals (G-C2, G-C4, G-C5) are long-standing pre-existing gaps in the sales pipeline — they don't block this branch's merge to `main`, but they block prod cutover. G-C1 and G-C3 are this-branch issues and should be folded into the same fix pass as A1-A7.
|
||||
|
||||
### Suggested remediation order — addendum
|
||||
|
||||
After the A/B/C/D/E/F block from the main report:
|
||||
|
||||
1. **G-C1** — files folder UPDATE in `deleteFolderSoftRescue` transaction (1-line addition).
|
||||
2. **G-C2** — nullify `scratchpadNotes.linkedClientId` in `clientHardDelete` (1-line addition).
|
||||
3. **G-C3** — call `demoteSystemFolderOnEntityDelete` after client hard-delete (1-line addition).
|
||||
4. **G-C4 + G-C5** — wire 6 missing berth-rule + pipeline-advance triggers (~30 min total, spread across invoices.ts, interests.service.ts, interest-berths.service.ts, documents.service.ts).
|
||||
|
||||
Total addendum effort: ~1 hour for G-C1/G-C2/G-C3, ~30 min for G-C4/G-C5, plus 1 migration regen for I-4 if you choose to fix it now.
|
||||
3151
docs/superpowers/plans/2026-05-09-documents-folders.md
Normal file
3151
docs/superpowers/plans/2026-05-09-documents-folders.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,375 @@
|
||||
# Documents Hub Split + Auto-Filed Client Folders
|
||||
|
||||
**Status:** Draft — awaiting final review
|
||||
**Date:** 2026-05-10
|
||||
**Builds on:** Wave 11.B `feat/documents-folders` (per-port nestable `document_folders` tree, soft-rescue delete, sibling-name uniqueness)
|
||||
|
||||
## Overview
|
||||
|
||||
Today the CRM has two parallel document surfaces that confuse reps:
|
||||
|
||||
1. `/[port]/documents` — Documenso signature workflows only (rows in `documents`). Hub tabs are signing-status (`in_progress` / `awaiting_them` / `awaiting_me` / `completed` / `expired`). Carries the new `document_folders` tree (Wave 11.B).
|
||||
2. `/[port]/documents/files` — bare uploaded files only (rows in `files`). Has its **own** "folder" mechanism driven by `storagePath` prefix matching, completely disconnected from `document_folders`.
|
||||
|
||||
The signed PDF that Documenso produces lives in the `files` table (`documents.signed_file_id` points at it), but it has no folder home and no entity-driven grouping — reps can't find a client's signed contracts without going through the signing workflow row first.
|
||||
|
||||
This spec unifies both surfaces under a single hub with a stacked **Signing in progress / Files** layout, anchored by a per-port nestable folder tree that gains three system-managed roots (`Clients/`, `Companies/`, `Yachts/`). Each entity gets one auto-created subfolder on first need; signed PDFs from completed workflows auto-deposit into the owner's folder. The folder view is **owner-aggregated**: opening `Clients/Smith, John/` surfaces files attached to John, plus files of his linked companies and yachts, each rendered as a labelled subsection.
|
||||
|
||||
## Conceptual model
|
||||
|
||||
Three first-class concepts after this spec ships:
|
||||
|
||||
- **File** (`files` row) — a stored binary artifact (PDF/image/etc.) with one `folder_id` and entity FKs (`client_id` / `company_id` / `yacht_id`). The canonical "document" reps file and find. Produced by either direct upload or as the output of a completed signing workflow.
|
||||
- **Signing workflow** (`documents` row) — the _process_ of getting a PDF signed via Documenso. Lifecycle `draft` → `sent` → `partially_signed` → `completed`. Surfaces in the hub's Signing section while in-flight. On completion, produces a signed-PDF file; the workflow row itself becomes audit history accessed via a "view signing details" link on the resulting file. Stops appearing in user-facing folder views.
|
||||
- **Folder** (`document_folders` row) — per-port nestable tree (existing). Extended to hold both files and in-flight workflows. Gains three system-managed roots and per-entity auto-subfolders.
|
||||
|
||||
`documents.folder_id` stays meaningful for in-flight workflows (rep can file by deal/project). Becomes irrelevant on completion — the rendering layer hides completed workflows from folder views entirely.
|
||||
|
||||
`files.folder_id` is **new** (not in current schema) — added by this spec.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
### In scope
|
||||
|
||||
- New `files.folder_id` column + index, FK to `document_folders.id`
|
||||
- `document_folders` schema additions: `system_managed`, `entity_type`, `entity_id`, `archived_at`
|
||||
- Three system roots (`Clients/`, `Companies/`, `Yachts/`) auto-created on port init
|
||||
- Lazy per-entity subfolder creation on first auto-deposit or first manual upload
|
||||
- Auto-deposit logic in `handleDocumentCompleted` (set `files.folder_id` + entity FKs on signed PDF)
|
||||
- Owner-resolution chain (Owner-wins: `client_id ?? company_id ?? yacht_id` on workflow, falling back to interest)
|
||||
- Owner-aggregation projection in the files & documents listing endpoints
|
||||
- Symmetric relationship walking (Client ↔ Company ↔ Yacht via memberships and ownership)
|
||||
- Hub UI rebuild: stacked Signing/Files sections, owner-grouped headers, system-folder 🔒 markers
|
||||
- "View signing details" dialog on signed-PDF file rows
|
||||
- System-folder protection: rename/move/delete blocked at API + UI
|
||||
- Entity rename auto-syncs system folder name (transactional)
|
||||
- Entity archive applies `(archived)` suffix; entity hard-delete demotes to user folder with `(deleted)` suffix
|
||||
- Search box scope: current folder + descendants, results across both Signing and Files
|
||||
- Hub root view (no folder selected): port-wide Signing + recent Files
|
||||
- One-time backfill script: ensure system folders exist, set `files.folder_id` from entity FKs, copy entity FKs from completed workflows onto signed files
|
||||
- Removal of `/[port]/documents/files` route (301 redirect to `/[port]/documents`)
|
||||
- Removal of the legacy `storagePath`-prefix folder rendering
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- Permission/role changes beyond what `documents.view` and `documents.manage_folders` already gate
|
||||
- Bulk file actions (multi-select move, multi-select download zip) — separate work
|
||||
- Tagging or labels on files — separate work
|
||||
- Trash / restore for hard-deleted files (current behavior preserved)
|
||||
- Search across file _content_ (full-text PDF search) — current behavior preserved (search is title/filename only)
|
||||
- Per-port admin override for aggregation symmetry (rejected as needless setting at E11)
|
||||
- Per-user feature flag rollout — hard cutover (E rollout decision)
|
||||
- Native PDF preview rebuild — existing `FilePreviewDialog` reused
|
||||
|
||||
## Folder tree structure & governance
|
||||
|
||||
### System-managed roots and subfolders
|
||||
|
||||
Three reserved root folders are auto-created when a port is initialised:
|
||||
|
||||
```
|
||||
Clients/
|
||||
Companies/
|
||||
Yachts/
|
||||
```
|
||||
|
||||
Per-entity subfolders are created **lazily on first need** — when a workflow completes for that entity, when a rep manually uploads a file scoped to that entity, or when a rep clicks "Open folder" on the entity's detail page. Empty entities don't appear in the tree.
|
||||
|
||||
Subfolder naming:
|
||||
|
||||
- Default name = entity display name (client `firstName lastName` / company `name` / yacht `name`).
|
||||
- Numeric collision suffix: `Smith, John (2)`, `Smith, John (3)`, etc. Suffix appended to the _new_ (later-created) folder; existing folder names never change due to collision.
|
||||
- Auto-rename on entity rename — runs in the same DB transaction as the entity update.
|
||||
- Entity archive: `(archived)` suffix appended, folder shown muted in tree, auto-deposit blocked until restored.
|
||||
- Entity hard-delete: `(deleted)` suffix appended, `system_managed` flipped to `false` (folder demoted to a regular user folder; rep can rename/move/delete normally).
|
||||
|
||||
### System-folder protection
|
||||
|
||||
When `system_managed = true`:
|
||||
|
||||
- Rename API rejects with `ConflictError("System folders can't be renamed")`.
|
||||
- Move API rejects with `ConflictError("System folders can't be moved")`.
|
||||
- Delete API rejects with `ConflictError("System folders can't be deleted")`.
|
||||
- UI hides rename/move/delete actions in `FolderActionsMenu` for these rows.
|
||||
- UI displays a 🔒 marker next to the folder name.
|
||||
|
||||
The three roots themselves (`Clients/` / `Companies/` / `Yachts/`) are also `system_managed = true` and protected identically.
|
||||
|
||||
### User folders
|
||||
|
||||
User-created folders sit alongside the three system roots and inside any other folder (subject to existing depth/cycle rules from Wave 11.B). Standard CRUD via `documents.manage_folders` permission. Examples reps will create: `Templates/`, `Compliance/`, `Marketing PDFs/`.
|
||||
|
||||
## Routing on workflow completion
|
||||
|
||||
`handleDocumentCompleted` (in `src/app/api/webhooks/documenso/route.ts`) currently:
|
||||
|
||||
1. Verifies the Documenso secret.
|
||||
2. Downloads the fully signed PDF.
|
||||
3. Creates a `files` row for the signed PDF.
|
||||
4. Sets `documents.signed_file_id` to the new file id.
|
||||
5. Updates `documents.status = 'completed'`.
|
||||
|
||||
This spec extends the handler with steps 3a, 3b, 3c — inserted between (3) and (4):
|
||||
|
||||
```
|
||||
3a. resolveOwner(workflow):
|
||||
candidates = [
|
||||
workflow.client_id,
|
||||
workflow.company_id,
|
||||
workflow.yacht_id,
|
||||
workflow.interest?.primary_client_id,
|
||||
workflow.interest?.primary_company_id,
|
||||
workflow.interest?.primary_yacht_id,
|
||||
]
|
||||
return first non-null candidate (with its entity_type) OR null
|
||||
|
||||
3b. if owner != null:
|
||||
folder = ensureEntityFolder(port_id, owner.entity_type, owner.entity_id)
|
||||
// INSERT … ON CONFLICT (port_id, entity_type, entity_id) DO NOTHING RETURNING id
|
||||
// re-SELECT on conflict to get the existing folder's id
|
||||
file.folder_id = folder.id
|
||||
// copy entity FK to file row if not already set (so aggregation reads file FKs as source of truth)
|
||||
file[`${owner.entity_type}_id`] ??= owner.entity_id
|
||||
|
||||
3c. if owner == null:
|
||||
file.folder_id remains null
|
||||
// file lives at root, surfaced in the root-view Files section
|
||||
```
|
||||
|
||||
Owner resolution happens at **completion time**, not creation time — if the rep edited the workflow's owner mid-signing (rare), the signed PDF lands in the most recent owner's folder.
|
||||
|
||||
The workflow's own `folder_id` is not touched. After `status = 'completed'`, the rendering layer hides the workflow from folder views; only the resulting signed file is visible (with a "view signing details" link to the workflow + signers + events timeline).
|
||||
|
||||
## Owner-aggregation projection
|
||||
|
||||
The killer feature. When a rep opens an entity folder (`Clients/Smith, John/`), the listing query is **not** a simple `WHERE folder_id = …` — it's a projection that walks the relationship graph and groups results by owner-source.
|
||||
|
||||
### Aggregation graph
|
||||
|
||||
Aggregation is **symmetric** (E aggregation reach decision). Walking from any entity, surface files attached to:
|
||||
|
||||
- the entity itself (DIRECTLY ATTACHED)
|
||||
- linked clients via `company_memberships`
|
||||
- linked companies via `company_memberships` and via yacht ownership
|
||||
- linked yachts via current ownership (`yachts.current_owner_type` + `current_owner_id`)
|
||||
- - any second-degree links (e.g., `Clients/Smith` shows files of `Smith Marine LLC`'s yachts via the chain Smith → Smith Marine LLC → owned yachts)
|
||||
|
||||
Each result group is rendered with a labelled header: `DIRECTLY ATTACHED · 3`, `FROM COMPANY — SMITH MARINE LLC · 1`, `FROM YACHT — MV SERENITY · 2`, etc. Files lived where they were physically filed (e.g., `Yachts/MV Serenity/`); the aggregation only borrows them for display, with a `lives in <path>` caption per row.
|
||||
|
||||
### Source-of-truth: file FKs
|
||||
|
||||
Aggregation reads each file's own `client_id` / `company_id` / `yacht_id` (snapshotted at upload/creation time), **not** the linked entity's current relationships. This makes yacht ownership transfer a no-op for historical files: a file uploaded for John when he owned MV Serenity stays under John's view forever, even after the yacht is sold to Mary. Mary's view shows files uploaded after the transfer (which carry `client_id = Mary`). Both clients' folders coexist with their respective historical artifacts.
|
||||
|
||||
### Per-group pagination
|
||||
|
||||
Each owner-source group renders its top 20 rows by `created_at desc`. When a group has more, a `Show all (148)` link drills into a flat paginated list scoped to that source. Keeps page render bounded for large portfolios (200+ yacht leasing clients).
|
||||
|
||||
### Defense-in-depth port_id
|
||||
|
||||
Every join in the aggregation SQL filters `port_id = $port` — at the entity table, at the membership table, at the yacht table, at the file table. Project pattern (per CLAUDE.md "defense-in-depth port_id scope" / berth recommender precedent). Single-place port_id check at the entry point alone is rejected — it bit the recommender exactly once and we fixed it the same way.
|
||||
|
||||
## UI layout
|
||||
|
||||
### Layout A: stacked sections, owner-labelled groups inside each
|
||||
|
||||
Confirmed in mockup review.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ /port-nimara/documents → Clients / Smith, John 🔒 │
|
||||
├──────────────┬──────────────────────────────────────────────────────┤
|
||||
│ FOLDERS │ Clients › Smith, John 🔒 [Upload] [+ Sign] │
|
||||
│ │ │
|
||||
│ 📁 Clients │ ⏳ SIGNING IN PROGRESS · 2 │
|
||||
│ 📁 Smith…🔒│ FROM CLIENT │
|
||||
│ 📁 … │ ▢ EOI · Berth A12 · sent 2d ago Awaiting them │
|
||||
│ 📁 Companies│ FROM YACHT — MV SERENITY │
|
||||
│ 📁 Yachts │ ▢ NDA · sent yesterday Awaiting them │
|
||||
│ │ │
|
||||
│ 📁 Templates│ 📎 FILES │
|
||||
│ 📁 Complian.│ DIRECTLY ATTACHED · 3 │
|
||||
│ │ ▢ Signed EOI · A11.pdf signed Apr 14 · view sig… │
|
||||
│ + New folder│ ▢ Passport scan.pdf uploaded Mar 2 │
|
||||
│ │ │
|
||||
│ │ FROM COMPANY — SMITH MARINE LLC · 1 │
|
||||
│ │ ▢ Articles of inc.pdf · lives in Companies/… │
|
||||
│ │ │
|
||||
│ │ FROM YACHT — MV SERENITY · 2 │
|
||||
│ │ ▢ Signed NDA.pdf · lives in Yachts/… │
|
||||
│ │ ▢ Survey report.pdf · lives in Yachts/… │
|
||||
└──────────────┴──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Layout primitives:
|
||||
|
||||
- **Left panel:** existing `FolderTree` extended for 🔒 markers and `system_managed`-aware action suppression (rename/move/delete hidden in `FolderActionsMenu`).
|
||||
- **Main panel:** breadcrumb + actions row, then stacked Signing/Files sections. Each section has its in-section grouped headers.
|
||||
- **Signing section:** hidden entirely when no in-flight workflows match the entity scope. When present, renders above Files.
|
||||
- **Files section:** always present (may be empty with placeholder).
|
||||
- **"View signing details" link:** appears on rows for signed-PDF files (those whose source can be traced via `documents.signed_file_id`). Click opens `<SigningDetailsDialog>` — modal showing signers, events, timeline, signed-at timestamps.
|
||||
|
||||
### Hub root view (no folder selected)
|
||||
|
||||
Default landing when rep clicks Documents in the sidebar:
|
||||
|
||||
- **Signing section:** all in-flight workflows port-wide (effectively today's `/[port]/documents` hub behavior, minus the signing-status sub-tabs which collapse).
|
||||
- **Files section:** recently uploaded/modified files port-wide, paginated by `updated_at desc`.
|
||||
|
||||
The folder tree on the left is the primary navigation; root view is the "I just opened the hub, show me what's recent" landing.
|
||||
|
||||
### Old `/[port]/documents/files` route
|
||||
|
||||
Removed. Server-side 301 redirect to `/[port]/documents`. The `<Files…>` components and the legacy `storagePath`-prefix folder code are deleted.
|
||||
|
||||
### Hub-tab simplification
|
||||
|
||||
Today's signing-status tabs (`in_progress` / `eoi_queue` / `awaiting_them` / `awaiting_me` / `completed` / `expired`) collapse into one Signing section — the rep will filter by signer-status via in-section chips if needed, but the dominant navigation is folders, not signing-status. The `documentsHubTabs` enum + `tab` query param are removed; `hub-counts` API endpoint is reduced to "in-flight count" only (used for the Signing section's counter badge).
|
||||
|
||||
## Edge cases — decisions
|
||||
|
||||
| ID | Edge case | Decision |
|
||||
| -------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| E1 | Entity renamed | System folder name auto-syncs in the same transaction. |
|
||||
| E2 | Two entities collide on folder name (e.g., both "Smith, John") | Append numeric suffix `(2)`, `(3)` to the **new** colliding folder. Existing folders never change. |
|
||||
| E3 | Entity archived | Folder stays with `(archived)` suffix, muted style. Auto-deposit halts. |
|
||||
| E4 | Entity hard-deleted | Folder gets `(deleted)` suffix, `system_managed` flips to `false` (rep can clean up). Files retain orphaned data. |
|
||||
| E5 | Yacht ownership transferred | Files snapshot their entity FKs at upload time. Old client folder retains historical files; new client gets a new folder for files created after transfer. Both coexist. |
|
||||
| E6 | Workflow's owner FK changes mid-signing | Resolve owner at completion time. Signed PDF lands in current owner's folder. |
|
||||
| E7 | Rep moves a file out of a system folder | Allowed. `folder_id` changes; entity FK is unchanged so aggregation still surfaces it via FK. The "lives in …" caption updates. |
|
||||
| E8 | Rep manually uploads into an entity folder | Auto-set the file's matching entity FK from the destination folder's `entity_type` + `entity_id`. Custom folders → no auto-mapping. |
|
||||
| E9 | Workflow has no entity at all | Signed PDF lands at root with `folder_id = null`. Surfaces in root-view Files section only. |
|
||||
| E10 | File/workflow attached to interest only, interest has no resolved owner | Same as E9 — root, null folder. Manual move or future backfill resolves later. |
|
||||
| E11 | Aggregated view returns 1000+ files | Top 20 per owner-source group, `Show all (N)` drilldown into flat paginated list per source. |
|
||||
| E12 | Hub root view (no folder selected) | Port-wide Signing + recent Files, both paginated. |
|
||||
| E13 | Concurrent completions race for the same entity folder | `INSERT … ON CONFLICT DO NOTHING RETURNING id`, then re-`SELECT` if needed. Uses the new partial unique index `uniq_document_folders_entity`. |
|
||||
| E14 | Cross-port aggregation leak | `port_id = $p` filter at every join in aggregation SQL. Defense-in-depth. |
|
||||
| Lazy folder creation | When are system root + per-entity folders created? | Roots: on port init. Subfolders: lazy on first need (auto-deposit, manual upload, or "Open folder" button on entity page). |
|
||||
| Aggregation reach | Symmetric or owner-down only? | Symmetric — walk relationships in both directions. `Clients/Smith/`, `Companies/Smith Marine LLC/`, `Yachts/MV Serenity/` all show the full graph from their vantage point. |
|
||||
| Search scope | Where does the search box look? | Current folder + descendants. Empty/root selection → port-wide. Includes both Signing and Files results. |
|
||||
| Rollout | Feature flag or hard cutover? | Hard cutover. Migration backfills data; new hub replaces old hub on merge. |
|
||||
|
||||
## Schema deltas
|
||||
|
||||
### `files` table
|
||||
|
||||
```sql
|
||||
ALTER TABLE files
|
||||
ADD COLUMN folder_id text REFERENCES document_folders(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_files_folder ON files(folder_id);
|
||||
CREATE INDEX idx_files_port_folder ON files(port_id, folder_id);
|
||||
```
|
||||
|
||||
### `document_folders` table
|
||||
|
||||
```sql
|
||||
ALTER TABLE document_folders
|
||||
ADD COLUMN system_managed boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN entity_type text, -- null | 'root' | 'client' | 'company' | 'yacht'
|
||||
ADD COLUMN entity_id text, -- null when entity_type is null or 'root'
|
||||
ADD COLUMN archived_at timestamptz; -- mirrors entity archive state
|
||||
|
||||
-- Per-port uniqueness on (entity_type, entity_id) for entity subfolders.
|
||||
-- Excludes 'root' folders (handled by name uniqueness already in place).
|
||||
CREATE UNIQUE INDEX uniq_document_folders_entity
|
||||
ON document_folders(port_id, entity_type, entity_id)
|
||||
WHERE entity_id IS NOT NULL;
|
||||
|
||||
-- Enforce: system_managed=true requires either entity_type='root' OR (entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL).
|
||||
ALTER TABLE document_folders
|
||||
ADD CONSTRAINT chk_system_folder_shape CHECK (
|
||||
NOT system_managed OR
|
||||
entity_type = 'root' OR
|
||||
(entity_type IN ('client','company','yacht') AND entity_id IS NOT NULL)
|
||||
);
|
||||
```
|
||||
|
||||
### Backfill migration (one-time data migration script)
|
||||
|
||||
Runs as part of the deploy. Idempotent — safe to re-run.
|
||||
|
||||
1. For every port: ensure `Clients/`, `Companies/`, `Yachts/` exist with `system_managed=true`, `entity_type='root'`.
|
||||
2. For every `(client | company | yacht)` entity that has at least one file or completed workflow attached: ensure its subfolder exists.
|
||||
3. For every file with a non-null `client_id` / `company_id` / `yacht_id`: set `folder_id` to the matching subfolder via owner-resolution (Owner-wins).
|
||||
4. For every completed workflow with `signed_file_id`: ensure the signed file's entity FKs are populated by copying from the workflow row (handles legacy completions where the signed file row was created without entity FKs).
|
||||
5. Files with no entity FKs → `folder_id` left null.
|
||||
|
||||
Script: `pnpm tsx scripts/backfill-document-folders.ts`. Wraps in `pg_advisory_xact_lock(<port_id_hash>)` per port to serialize concurrent runs.
|
||||
|
||||
## Implementation surface (preview, full breakdown in the plan)
|
||||
|
||||
### Service layer
|
||||
|
||||
- `src/lib/services/document-folders.service.ts`
|
||||
- `ensureEntityFolder(portId, entityType, entityId)` — INSERT-ON-CONFLICT + re-SELECT
|
||||
- `ensureSystemRoots(portId)` — idempotent root creation
|
||||
- `syncEntityFolderName(portId, entityType, entityId, newName)` — called from entity update services
|
||||
- `applyEntityArchivedSuffix(portId, entityType, entityId)` / `applyEntityRestoredSuffix(...)` — toggle `(archived)` suffix
|
||||
- `demoteSystemFolderOnEntityDelete(portId, entityType, entityId)` — flip `system_managed=false`, append `(deleted)` suffix
|
||||
- `src/lib/services/files.service.ts`
|
||||
- `listFilesInFolder(portId, folderId, opts)` — direct listing (folder_id match)
|
||||
- `listFilesAggregatedByEntity(portId, entityType, entityId, opts)` — owner-grouped projection
|
||||
- `applyEntityFkFromFolder(portId, folderId, fileInsert)` — used by upload endpoints (E8)
|
||||
- `src/lib/services/documents.service.ts`
|
||||
- `listInflightWorkflowsAggregatedByEntity(...)` — same projection for in-flight workflows
|
||||
- `src/lib/services/clients.service.ts` / `companies.service.ts` / `yachts.service.ts`
|
||||
- Add hooks to call `syncEntityFolderName` on rename, `applyEntityArchivedSuffix` on archive/restore, `demoteSystemFolderOnEntityDelete` on hard delete
|
||||
|
||||
### API routes
|
||||
|
||||
- `src/app/api/v1/files/route.ts` — accept `folderId` (direct) or `entityType + entityId` (aggregated) query params
|
||||
- `src/app/api/v1/documents/route.ts` — same; collapse `tab` enum to a `signingState` filter (in-flight only by default)
|
||||
- `src/app/api/v1/documents/hub-counts/route.ts` — reduce to in-flight count
|
||||
- `src/app/api/v1/documents/[id]/signing-details/route.ts` — **new** — returns workflow + signers + events for the dialog
|
||||
- `src/app/api/webhooks/documenso/route.ts` (`handleDocumentCompleted`) — extend with owner-resolve + ensure-folder + set-FK steps
|
||||
|
||||
### UI components
|
||||
|
||||
- `src/components/documents/documents-hub.tsx` — major rebuild: stacked Signing/Files sections, owner-grouped headers, system-folder integration. Drop the signing-status tabs.
|
||||
- `src/components/documents/folder-tree.tsx` — render 🔒 marker for `system_managed`; suppress rename/move/delete in `FolderActionsMenu` for system rows
|
||||
- `src/components/documents/aggregated-section.tsx` — **new** — renders a Signing or Files section grouped by owner-source with per-group pagination
|
||||
- `src/components/documents/signing-details-dialog.tsx` — **new** — modal for "view signing details"
|
||||
- `src/app/(dashboard)/[portSlug]/documents/files/page.tsx` — **deleted**, replaced by 301 redirect in `next.config.mjs`
|
||||
- `src/components/files/folder-tree.tsx` and the legacy `storagePath`-prefix logic — **deleted**
|
||||
|
||||
### Stores / hooks
|
||||
|
||||
- `src/stores/file-browser-store.ts` — repurposed to drive the unified hub state (currentFolder, viewMode); the legacy storagePath-keyed currentFolder semantics are replaced with `document_folders.id` references
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Unit (vitest)
|
||||
|
||||
- `document-folders.service.test.ts`: extend with system-folder tests — `ensureEntityFolder` idempotency, `syncEntityFolderName` collision (numeric suffix), `applyEntityArchivedSuffix` round-trip, `demoteSystemFolderOnEntityDelete` flips `system_managed`.
|
||||
- `files.service.aggregated.test.ts`: aggregation projection — symmetric walk, defense-in-depth port_id, per-group pagination, file-FK-as-source-of-truth (yacht transfer scenario).
|
||||
- `documents-completion.handler.test.ts`: `handleDocumentCompleted` with each owner-resolution branch (client direct, company direct, yacht direct, via interest, no owner).
|
||||
|
||||
### Integration (vitest + real Postgres)
|
||||
|
||||
- `documents-hub-system-folders.integration.test.ts`: API-level — listing aggregated, system folder protection (rename/move/delete return 4xx), entity rename round-trips, archive/delete lifecycle.
|
||||
- `backfill-document-folders.integration.test.ts`: backfill script idempotency, multi-port isolation, legacy file FK propagation from completed workflows.
|
||||
|
||||
### E2E (Playwright)
|
||||
|
||||
- `documents-hub-aggregated.smoke.spec.ts`: open client folder → see grouped Signing + Files → open signing-details dialog → close.
|
||||
- `documents-hub-upload-into-entity-folder.smoke.spec.ts`: upload PDF into Clients/Smith/ → verify `client_id` auto-set → verify file appears in entity folder.
|
||||
- `documents-hub-completion-auto-deposit.realapi.spec.ts`: round-trip Documenso completion → verify signed PDF lands in owner's entity folder. (Joins the existing realapi project.)
|
||||
|
||||
### Visual
|
||||
|
||||
- Regenerate baselines for `/[port]/documents` (root view) and `/[port]/documents` with a folder selected. Snapshot key: hub-root, hub-entity-folder.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Aggregation queries slow on large portfolios (5k+ files per client) | Per-group pagination caps render cost; supporting indexes on `files(port_id, client_id)`, `files(port_id, company_id)`, `files(port_id, yacht_id)` already exist; new `files(folder_id)` and `files(port_id, folder_id)` cover folder filtering |
|
||||
| Backfill migration locks production for too long | Per-port advisory lock; backfill batched in chunks of 1000 file rows per transaction; safe to re-run if interrupted |
|
||||
| System-folder protection bypass via direct DB write | Application-level enforcement; we accept that direct DB writes can bypass (no DB constraint enforces "you can't update system_managed=true rows"). Audit log entries on folder ops surface anomalies |
|
||||
| Hard cutover means broken hub if backfill fails | Backfill is idempotent and runs _before_ code rollout; if backfill fails, the new hub still renders (just with sparse folders); rollback = revert the migration + redeploy old hub binary |
|
||||
| Rep confused by "view signing details" link disappearing for non-Documenso signed files (e.g., manually uploaded "already signed" PDFs via /upload-signed) | The link shows only when `signed_file_id` traces to a `documents` row; manually-uploaded "signed" PDFs that bypass the workflow won't have the link, which is correct — there's nothing to show |
|
||||
|
||||
## Open questions deferred to plan
|
||||
|
||||
- Whether to add a "Signing status" filter chip strip inside the Signing section (the deferred replacement for `awaiting_them`/`awaiting_me` tabs). Default: defer; add if rep feedback asks for it.
|
||||
- Whether `Signing section in entity folders` should also surface workflows whose `interest_id` resolves to the entity (not just direct entity FK match). Default: yes, via the same Owner-wins resolution chain — codify in the projection helper.
|
||||
491
docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md
Normal file
491
docs/superpowers/specs/2026-05-12-pdf-stack-overhaul-design.md
Normal file
@@ -0,0 +1,491 @@
|
||||
# PDF Stack Overhaul — Design
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Branch:** `feat/documents-folders`
|
||||
**Status:** Design approved; pending user review of spec; implementation planned via writing-plans skill.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace `pdfme` (3 deps, 8 hand-coded coordinate templates, 571-line TipTap-to-pdfme bridge) with `@react-pdf/renderer` (JSX components, real layout primitives). Add `unpdf` for berth-PDF tier-2 rasterization. Add port-level logo upload with quality safeguards. Migrate only the internal-only PDF surfaces; remove invoice and admin-TipTap PDF generation entirely (they violate the new "no client-facing CRM-generated PDFs" rule).
|
||||
|
||||
## Scope (locked)
|
||||
|
||||
### KEEP & migrate to `@react-pdf/renderer` (internal-only)
|
||||
|
||||
| Surface | Current location | Caller |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| Activity report | `src/lib/pdf/templates/reports/activity-report.ts` | `src/lib/services/reports.service.ts` |
|
||||
| Revenue report | `src/lib/pdf/templates/reports/revenue-report.ts` | same |
|
||||
| Pipeline report | `src/lib/pdf/templates/reports/pipeline-report.ts` | same |
|
||||
| Occupancy report | `src/lib/pdf/templates/reports/occupancy-report.ts` | same |
|
||||
| Client summary export | `src/lib/pdf/templates/client-summary-template.ts` | `src/lib/services/record-export.ts` |
|
||||
| Berth spec export | `src/lib/pdf/templates/berth-spec-template.ts` | same |
|
||||
| Interest summary export | `src/lib/pdf/templates/interest-summary-template.ts` | same |
|
||||
| Expense sheet | `src/lib/services/expense-pdf.service.ts` (currently uses pdfme indirectly via `expense-export.ts`) | same |
|
||||
|
||||
### REMOVE entirely
|
||||
|
||||
| Removal | Reason |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `src/lib/pdf/templates/invoice-template.ts` + `generatePdf` call in `invoices.ts:604` + API route `/api/v1/invoices/[id]/generate-pdf` | Invoices are client-facing; no CRM-generated client-facing PDFs. Future invoice rendering will use the deferred AcroForm-fill admin-template feature. |
|
||||
| `src/lib/pdf/tiptap-to-pdfme.ts` (571 lines) + API route `/api/v1/admin/templates/preview` + `generatePdf` block in `document-templates.ts:516` | TipTap document templates are Documenso seed bodies; CRM does not render them to PDF anymore. |
|
||||
| `src/lib/pdf/templates/eoi-standard-inapp.ts` (337 lines, HTML seed) + seed-data references | Only used as the seed `bodyHtml` text on a `document_templates` row. The in-app EOI is rendered by `fill-eoi-form.ts` (pdf-lib), not from this HTML. Safe to drop. |
|
||||
| `src/lib/pdf/generate.ts` (24 lines) | Pdfme wrapper; replaced by `src/lib/pdf/render.ts`. |
|
||||
| Deps: `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas` | Replaced by `@react-pdf/renderer`. |
|
||||
|
||||
### STAYS UNTOUCHED
|
||||
|
||||
- `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm fill on `assets/eoi-template.pdf`) — the in-app EOI pathway.
|
||||
- `src/lib/services/berth-pdf-parser.ts` tier-1 (pdf-lib AcroForm read) and tier-3 (AI fallback). Tier-2 (Tesseract OCR) gets `unpdf` for PDF→image rasterization.
|
||||
- `pdf-lib` dep (still needed by `fill-eoi-form.ts` and `berth-pdf-parser.ts`).
|
||||
- All Documenso integration code.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three orthogonal PDF paths post-migration, each with a single owner:
|
||||
|
||||
```
|
||||
┌──────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐
|
||||
│ react-pdf (this phase) │ │ pdf-lib AcroForm fill │ │ Documenso (external) │
|
||||
│ Internal only │ │ Standardized + signing │ │ Client-facing signed │
|
||||
│ │ │ │ │ docs │
|
||||
│ • Reports (×4) │ │ • In-app EOI │ │ │
|
||||
│ • Expenses │ │ • Future admin-upload │ │ (handled outside our │
|
||||
│ • Record exports (×3) │ │ invoice templates │ │ system) │
|
||||
│ • Future internal lists │ │ (deferred) │ │ │
|
||||
└────────────┬─────────────┘ └────────────┬─────────────┘ └────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
src/lib/pdf/render.ts src/lib/pdf/fill-eoi-form.ts
|
||||
(renderToBuffer + (unchanged this phase)
|
||||
renderToStream)
|
||||
│
|
||||
▼
|
||||
src/lib/pdf/brand-kit/
|
||||
├─ DocumentShell.tsx
|
||||
├─ Header.tsx
|
||||
├─ Footer.tsx
|
||||
├─ DataTable.tsx
|
||||
├─ KeyValueGrid.tsx
|
||||
├─ Section.tsx
|
||||
├─ Badge.tsx
|
||||
├─ charts/{Bar,Line,Pie,Funnel}Chart.tsx
|
||||
├─ tokens.ts
|
||||
└─ logo.ts
|
||||
```
|
||||
|
||||
### Module boundaries
|
||||
|
||||
- **`brand-kit/`** — pure presentation primitives. No DB access, no CRM domain knowledge. Each component has typed props and renders react-pdf elements.
|
||||
- **`templates/`** — one `.tsx` per document type. Imports brand-kit primitives + receives typed data props. No DB access; data fetching stays in the calling service.
|
||||
- **`render.ts`** — the only module that touches `@react-pdf/renderer`'s `renderToBuffer` / `renderToStream`. Services call `renderPdf(<MyTemplate {...data} />)` or `renderPdfStream(<MyTemplate {...data} />)`.
|
||||
- **`logo.ts`** — `resolvePortLogo(portId)` reads `system_settings.port_logo_file_id` and returns `{ source, buffer, mimeType }`. Cached per request via React `cache()`.
|
||||
- **Chart rendering** — pure SVG components emitting react-pdf's native `<Svg>` primitive. No JSDOM, no headless Chrome, no canvas. Server-rendered like any other PDF component.
|
||||
- **Photo embedding** (expense PDFs) — `sharp` (existing dep) compresses each receipt to ~150KB JPEG before embed. Stream-renders pages so memory stays bounded with hundreds of entries.
|
||||
|
||||
### Header layout constraint
|
||||
|
||||
The brand-kit `<Header>` reserves a fixed logo slot:
|
||||
|
||||
```
|
||||
maxWidth: 200 (≈ 56mm)
|
||||
maxHeight: 60 (≈ 17mm)
|
||||
objectFit: contain // letterbox, never stretch
|
||||
align: left, vertically centered within the dark header band
|
||||
fallback: when resolvePortLogo returns 'fallback', render <Text style={bold}>{port.name}</Text>
|
||||
at the same slot. The port-name + doc-title combination keeps the header visually balanced.
|
||||
```
|
||||
|
||||
This is enforced inside `<Header>`, not at upload time, so the upload pipeline can accept any 200-1200px logo and trust the layout to letterbox correctly.
|
||||
|
||||
### Brand kit tokens
|
||||
|
||||
```ts
|
||||
// src/lib/pdf/brand-kit/tokens.ts
|
||||
export const PDF_TOKENS = {
|
||||
colors: {
|
||||
text: '#111111',
|
||||
textMuted: '#666666',
|
||||
border: '#e5e7eb',
|
||||
headerBand: '#0f172a', // dark slate — matches CRM sidebar
|
||||
headerText: '#ffffff',
|
||||
accentBlue: '#1d4ed8',
|
||||
zebra: '#f9fafb',
|
||||
success: '#16a34a',
|
||||
warning: '#d97706',
|
||||
danger: '#dc2626',
|
||||
},
|
||||
fonts: {
|
||||
sans: 'Helvetica',
|
||||
sansBold: 'Helvetica-Bold',
|
||||
mono: 'Courier',
|
||||
},
|
||||
sizes: {
|
||||
docTitle: 18,
|
||||
sectionH: 13,
|
||||
body: 10,
|
||||
small: 8,
|
||||
caption: 7,
|
||||
},
|
||||
spacing: {
|
||||
pagePadding: 36,
|
||||
sectionGap: 18,
|
||||
rowGap: 6,
|
||||
},
|
||||
} as const;
|
||||
```
|
||||
|
||||
Single source of truth. Future design pass = edit this file, every PDF updates.
|
||||
|
||||
## Logo handling
|
||||
|
||||
### Layer 1 — Server-side sharp normalization (required)
|
||||
|
||||
```
|
||||
upload → magic-byte check via sharp metadata (PNG | JPEG | WEBP | SVG | HEIC | HEIF | AVIF)
|
||||
→ reject animated GIF / multi-frame PNG / multi-page TIFF
|
||||
→ size cap 5MB raw
|
||||
→ if SVG:
|
||||
sanitize first via svgo (strip <script>, on*=, <foreignObject>, external href)
|
||||
reject if sanitization removed dangerous nodes
|
||||
rasterize to PNG via sharp(buf, { density: 300 }) // 300 DPI from vector
|
||||
→ standard pipeline:
|
||||
sharp(buf)
|
||||
.extract({ left: cropX, top: cropY, width: cropW, height: cropH }) ← from client crop
|
||||
.trim({ threshold: 10 })
|
||||
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true })
|
||||
.toColorspace('srgb')
|
||||
.removeAlpha()-if-jpeg-source-and-near-white
|
||||
.png({ compressionLevel: 9, palette: true }) ← palette where possible for smaller files
|
||||
.toBuffer()
|
||||
→ reject if final > 1MB
|
||||
→ reject if min dimension after trim < 200px
|
||||
→ store via getStorageBackend().put()
|
||||
→ set system_settings.port_logo_file_id = files.id (atomic upsert)
|
||||
→ soft-archive previous logo's files row (archivedAt = now)
|
||||
→ write audit_logs entry: action=branding.logo.uploaded, by=user.id
|
||||
→ collect warnings: [trimmed, resized, noAlpha, jpegSource, svgRasterized, heicConverted]
|
||||
```
|
||||
|
||||
**Why rasterize SVGs to PNG at upload time:** react-pdf's `<Svg>` primitive supports a subset of SVG (Path, Rect, Circle, Line, Text, gradients, clip-paths) but not filters, animations, embedded fonts, or all the quirks of a designer-exported SVG. Sharp rasterizes via librsvg at 300 DPI on upload, eliminating runtime surprises. Single PNG to embed at render time. The vector source is captured-in-time; if the admin later needs higher resolution, they re-upload.
|
||||
|
||||
**Why HEIC/AVIF support:** iPhone photo exports default to HEIC; common admin pain point. Sharp handles both natively via libheif; converts to PNG in the pipeline. Less common but worth supporting.
|
||||
|
||||
### Layer 2 — Live upload UI
|
||||
|
||||
Admin opens **Port Settings → Branding → Logo**. The dialog shows:
|
||||
|
||||
1. **Rules above the dropzone:**
|
||||
- Use PNG or SVG with a transparent background
|
||||
- Minimum 200×200px; recommended 600×200px (wide) or 400×400px (square)
|
||||
- Max 5MB; we'll auto-trim and optimize
|
||||
- Avoid JPEGs unless the background is solid white
|
||||
|
||||
2. **`react-image-crop` cropper** with aspect-ratio toggle (Wide 3:1 / Square 1:1 / Freeform).
|
||||
|
||||
3. **Live HTML preview** rendering the actual brand-kit `<Header>` React component beside the cropper, with the user's logo. Two preview swatches: dark header band (where the logo actually appears) and a colored background (to spot the "white box" problem with non-transparent JPEGs).
|
||||
|
||||
4. **Post-upload warnings** displayed in the preview:
|
||||
- "JPEG with no alpha channel — white background will show on dark headers"
|
||||
- "Logo trimmed to remove whitespace borders"
|
||||
- "Resized from 4000×4000 to 1200×1200"
|
||||
|
||||
5. **"Test with sample PDF" button** — hits a sample-PDF endpoint that renders a minimal report header and streams it back. Browser opens in a new tab.
|
||||
|
||||
### Layer 3 — `react-image-crop` integration
|
||||
|
||||
Client renders the original image inside `react-image-crop` with a constrained aspect ratio. On save:
|
||||
|
||||
1. Client sends `multipart/form-data` with `file` + `{ cropX, cropY, cropW, cropH }` JSON sidecar.
|
||||
2. Server runs the sharp pipeline above with the crop applied as the first step.
|
||||
|
||||
This keeps sharp as the single source of truth (no canvas-tainted-CORS issues client-side; the actual crop happens server-side using the user-provided coordinates).
|
||||
|
||||
### Storage path
|
||||
|
||||
Logos use the existing pluggable storage backend (`src/lib/storage/`). Object key shape:
|
||||
|
||||
```
|
||||
ports/{portId}/branding/logo-{uuid}.png
|
||||
```
|
||||
|
||||
The same backend currently serves brochures, berth PDFs, gdpr exports, etc. — `s3` for prod, `filesystem` for single-node dev. Logos inherit whatever's configured; no special routing. Trivial-image-inline-in-DB would save one S3 round-trip per PDF render but break consistency with every other file artifact; not worth it.
|
||||
|
||||
### Permission gating
|
||||
|
||||
The upload endpoint is wrapped with `withAuth(withPermission('port_settings', 'manage', …))` (same gate currently used for brochures admin, send-from accounts, etc.). Audit trail goes to `audit_logs` (`action: branding.logo.uploaded`, `entityType: port`, `entityId: portId`). Soft-archive of the prior logo file row is logged as `branding.logo.archived`.
|
||||
|
||||
### Resolution at render time
|
||||
|
||||
```ts
|
||||
// src/lib/pdf/brand-kit/logo.ts
|
||||
export const resolvePortLogo = cache(
|
||||
async (
|
||||
portId: string,
|
||||
): Promise<{
|
||||
source: 'logo' | 'fallback';
|
||||
buffer: Buffer | null;
|
||||
mimeType: 'image/png' | 'image/svg+xml' | null;
|
||||
}> => {
|
||||
const setting = await getSystemSetting(portId, 'port_logo_file_id');
|
||||
if (!setting) return { source: 'fallback', buffer: null, mimeType: null };
|
||||
const file = await db.query.files.findFirst({ where: eq(files.id, setting) });
|
||||
if (!file || file.archivedAt) return { source: 'fallback', buffer: null, mimeType: null };
|
||||
const backend = await getStorageBackend();
|
||||
const buffer = await backend.get(file.storageKey);
|
||||
return { source: 'logo', buffer, mimeType: file.mimeType as 'image/png' | 'image/svg+xml' };
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Brand-kit `<DocumentShell>` internally calls this and passes the buffer down through context. Every template that wraps in `<DocumentShell port={port}>...</DocumentShell>` gets the logo automatically. No per-template wiring. When no logo is set, the header renders the port name as bold text instead.
|
||||
|
||||
## Per-template designs
|
||||
|
||||
### Reports — shared shell
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [LOGO] PORT NAME REPORT TITLE │
|
||||
│ generated 2026-05-12 18:44 Date-range badge │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Summary cards (3-4 KPI stat boxes) │
|
||||
│ ┌──────┬──────┬──────┐ │
|
||||
│ │
|
||||
│ ◌ CHART (full-width SVG) │
|
||||
│ │
|
||||
│ Detail Table (zebra rows, columns vary per report) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Port Name · Confidential · Page 1 of 3 · Generated … │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Report | Summary stat cards | Chart | Detail table columns |
|
||||
| --------- | ---------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| Activity | total events, top action, top user, busiest day | Stacked bar — events per day by action | date · action · entity type · entity · user |
|
||||
| Revenue | total revenue, paid, outstanding, avg invoice | Line — revenue per month + small pie paid/outstanding | invoice # · client · issued · due · amount · status |
|
||||
| Pipeline | total interests, win rate, avg cycle days, top stage | Funnel — count per stage | interest · client · stage · lead category · days in stage |
|
||||
| Occupancy | total berths, occupied %, available %, under-offer % | Time-series — occupancy % over period + small pie current status | berth # · status · current interest · last change |
|
||||
|
||||
### Expense PDF
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [LOGO] PORT NAME — Expense Sheet │
|
||||
│ Period: 2026-04-01 → 2026-04-30 · 247 entries │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Summary cards: total · by category · by status │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Expense entries (one row per entry, multi-page) │
|
||||
│ ┌──┬──────────┬──────────┬────────┬─────────┬─────────┐ │
|
||||
│ │# │ Date │ Category │ Vendor │ Amount │ Receipt │ │
|
||||
│ │ │ Notes: <inline notes line, optional> │ │
|
||||
│ │ │ [receipt photo, max 200×200, ~150KB JPEG] │ │
|
||||
│ └──┴──────────┴──────────┴────────┴─────────┴─────────┘ │
|
||||
│ Page break inserted between entries when remaining vertical │
|
||||
│ space < 200px (no orphan partial rows) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ Page 1 of 47 · Total: $48,232 · 247 entries │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Critical: **stream-render via `renderToStream`** because 247 entries × ~150KB photos = 37MB peak memory if all loaded at once. Stream renders one page at a time, freeing buffers as it goes. Each photo passes through `sharp.resize(800, 800, { fit: 'inside' }).jpeg({ quality: 70 })` once and is cached for the lifetime of the request.
|
||||
|
||||
### Record exports
|
||||
|
||||
- **Client Summary** — brand shell + key/value grid for client info + table for yachts + table for interests + activity timeline at bottom.
|
||||
- **Berth Spec** — brand shell + two-column key/value grid (info / dimensions / pricing / tenure) + infrastructure table + waiting-list table + maintenance-log table.
|
||||
- **Interest Summary** — brand shell + stage badge in header + key/value grids for client/yacht/berth + notes block + activity timeline.
|
||||
|
||||
## Data flow
|
||||
|
||||
### Caller migration pattern
|
||||
|
||||
Before:
|
||||
|
||||
```ts
|
||||
import { generatePdf } from '@/lib/pdf/generate';
|
||||
import {
|
||||
activityReportTemplate,
|
||||
buildActivityInputs,
|
||||
} from '@/lib/pdf/templates/reports/activity-report';
|
||||
const inputs = buildActivityInputs(data, port.name);
|
||||
const pdfBytes = await generatePdf(activityReportTemplate, inputs);
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```ts
|
||||
import { renderPdf } from '@/lib/pdf/render';
|
||||
import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report';
|
||||
const pdfBytes = await renderPdf(<ActivityReportPdf port={port} data={data} />);
|
||||
```
|
||||
|
||||
### Render module
|
||||
|
||||
```ts
|
||||
// src/lib/pdf/render.ts
|
||||
import { renderToBuffer, renderToStream } from '@react-pdf/renderer';
|
||||
import type { ReactElement } from 'react';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export async function renderPdf(element: ReactElement): Promise<Buffer> {
|
||||
try {
|
||||
return await renderToBuffer(element);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'PDF render failed');
|
||||
throw new Error('Failed to render PDF');
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderPdfStream(element: ReactElement): Promise<NodeJS.ReadableStream> {
|
||||
return renderToStream(element);
|
||||
}
|
||||
```
|
||||
|
||||
### Chart rendering (sketch)
|
||||
|
||||
```tsx
|
||||
// src/lib/pdf/brand-kit/charts/BarChart.tsx
|
||||
import { Svg, Line, Rect, Text as SvgText } from '@react-pdf/renderer';
|
||||
import { PDF_TOKENS } from '../tokens';
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
width = 480,
|
||||
height = 200,
|
||||
color = PDF_TOKENS.colors.accentBlue,
|
||||
}) {
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
const barW = (width - 60) / data.length;
|
||||
return (
|
||||
<Svg width={width} height={height}>
|
||||
<Line
|
||||
x1={40}
|
||||
y1={20}
|
||||
x2={40}
|
||||
y2={height - 30}
|
||||
strokeWidth={1}
|
||||
stroke={PDF_TOKENS.colors.border}
|
||||
/>
|
||||
<Line
|
||||
x1={40}
|
||||
y1={height - 30}
|
||||
x2={width - 10}
|
||||
y2={height - 30}
|
||||
strokeWidth={1}
|
||||
stroke={PDF_TOKENS.colors.border}
|
||||
/>
|
||||
{data.map((d, i) => {
|
||||
const h = (d.value / max) * (height - 60);
|
||||
return (
|
||||
<Rect
|
||||
key={i}
|
||||
x={50 + i * barW}
|
||||
y={height - 30 - h}
|
||||
width={barW - 4}
|
||||
height={h}
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{data.map((d, i) => (
|
||||
<SvgText
|
||||
key={i}
|
||||
x={50 + i * barW + (barW - 4) / 2}
|
||||
y={height - 14}
|
||||
textAnchor="middle"
|
||||
fontSize={7}
|
||||
>
|
||||
{d.label}
|
||||
</SvgText>
|
||||
))}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Same pattern for LineChart / PieChart / FunnelChart. ~60-100 lines each.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Failure mode | Detection | Surface |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Logo file missing at render time | `resolvePortLogo` returns `source: 'fallback'` | Header renders port-name text only; structured log warning. |
|
||||
| Logo file corrupt | `sharp` throws on load | 500 via `errorResponse(InternalError)`; structured log; admin sees "Logo file is unreadable, please re-upload." |
|
||||
| Chart data empty | Component prop validation in template | Render "No data for selected period" placeholder; no crash. |
|
||||
| Receipt photo missing (expense PDF) | Storage backend `get` throws | Skip photo for that entry; render "Receipt unavailable" placeholder text; continue; collect into `warnings[]` and log. |
|
||||
| Receipt photo unprocessable by sharp | `sharp` throws on resize | Same as above. |
|
||||
| Stream-render aborted mid-page | `renderToStream` rejects | Caller drains stream into try/catch; surface `errorResponse(error)`; partial bytes not stored. |
|
||||
| OOM on huge expense PDF | Heap monitor | Stream-render keeps peak bounded; cap entries at 1000 per PDF; prompt admin to split into multiple periods. |
|
||||
| Sharp pipeline rejects upload | Specific error code | 422 `ValidationError` with the rejection reason ("file > 5MB", "dimension < 200px", "unsupported format: GIF animated"). |
|
||||
| SVG with embedded JS or external href | `svgo` strips scripts; post-sanitize node-count check | Reject with `ValidationError('SVG contained disallowed nodes')`. |
|
||||
| Concurrent logo uploads (admin clicks save twice / two browser tabs) | Last-writer-wins via atomic `system_settings` upsert | Both `files` rows persist; only newer is pointed at. Soft-archive doesn't race because it operates on the OLD setting's file_id captured before the upsert. |
|
||||
| Mid-render logo upload | `resolvePortLogo` reads at render-start | In-flight PDF uses whichever logo was current when the request entered. Next request gets the new one. No mid-PDF logo swap. |
|
||||
| Logo dimensions wildly off the header aspect ratio | Brand-kit `<Header>` constrains logo to `maxWidth: 200, maxHeight: 60` with `objectFit: contain` | Logo letterboxes inside its slot; never distorts. |
|
||||
| Cropper coords out of bounds | Server-side validation against image metadata before sharp extract | 422 `ValidationError('Crop coordinates out of image bounds')`. |
|
||||
| File mime header lies (claims PNG, bytes are HTML) | Sharp's `metadata()` reads actual magic bytes, ignores declared mime | Sharp throws → 422 `ValidationError('File contents do not match a supported image format')`. |
|
||||
| Storage backend `put` fails (network glitch) | Catch around `backend.put` | Roll back: do not insert files row, do not change system_settings; return 503 with retry hint. |
|
||||
| `port_logo_file_id` setting points at archived/deleted file | `resolvePortLogo` checks `archivedAt` | Treat as missing; fall back to text header; structured log warning so ops notices. |
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit (vitest)
|
||||
|
||||
- `brand-kit/charts/*.test.tsx` — snapshot SVG output for known inputs.
|
||||
- `brand-kit/logo.test.ts` — `resolvePortLogo` with fixtures for: configured / missing / archived / corrupt.
|
||||
- `pdf/render.test.ts` — round-trip a tiny `<Page>` and verify the output starts with `%PDF-`.
|
||||
- `services/logo-upload.test.ts` — sharp pipeline for: PNG-with-alpha (passes) / JPEG (warning) / undersized (rejects) / oversized (resizes) / SVG (passthrough) / animated GIF (rejects) / SVG with script tag (rejects).
|
||||
|
||||
### Integration (vitest)
|
||||
|
||||
- Each template renders to bytes without throwing, given representative fixtures from seed data.
|
||||
- `reports.service.test.ts` — generate each of the 4 reports for a seeded port; assert PDF magic byte + non-zero length.
|
||||
- `record-export.test.ts` — generate client / berth / interest summaries for seeded entities.
|
||||
- `expense-export.test.ts` — generate expense PDF for 250 seeded entries; assert pages > 5; assert peak heap delta < 200MB (proxy for stream-render working).
|
||||
|
||||
### Playwright (smoke)
|
||||
|
||||
- New spec: `branding-logo-upload.spec.ts` — upload PNG, see preview, save, generate sample PDF, assert PDF downloads.
|
||||
- New spec: `reports-pdf-export.spec.ts` — for each of the 4 reports, click export, assert PDF downloads.
|
||||
- Existing specs: anywhere clicking "export PDF" was tied to pdfme, update assertion.
|
||||
|
||||
### Visual regression (existing visual project)
|
||||
|
||||
- 4 new baselines (one per report) using seed port's logo.
|
||||
- 3 new baselines (client / berth / interest summary).
|
||||
- 1 new baseline (expense PDF, first 2 pages).
|
||||
- Snapshots stored as PNG (rendered from PDF via first-page extraction).
|
||||
|
||||
## Migration sequence
|
||||
|
||||
| # | Commit | Files touched | Verifies |
|
||||
| --- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| 1 | Foundation: install deps + brand kit | +`@react-pdf/renderer`, +`unpdf`, +`react-image-crop`, +`svgo`; new `src/lib/pdf/brand-kit/*`, `src/lib/pdf/render.ts` | brand kit unit tests pass; nothing wired yet |
|
||||
| 2 | Logo upload feature | new `src/lib/services/logo.service.ts`, `src/app/api/v1/admin/branding/logo/*`, admin UI in port settings, `system_settings.port_logo_file_id` key | upload + preview + sample-PDF test work in dev |
|
||||
| 3 | Migrate activity report | port `activity-report.ts` → `activity-report.tsx`; rewire `reports.service.ts` caller; visual baseline | report exports work; visual diff approved |
|
||||
| 4 | Migrate revenue report | same shape | same |
|
||||
| 5 | Migrate pipeline report | same shape | same |
|
||||
| 6 | Migrate occupancy report | same shape | same |
|
||||
| 7 | Migrate client summary | port `client-summary-template.ts` → `.tsx`; rewire `record-export.ts` | same |
|
||||
| 8 | Migrate berth spec | same | same |
|
||||
| 9 | Migrate interest summary | same | same |
|
||||
| 10 | Migrate expense PDF | port `expense-pdf.service.ts` to react-pdf streaming; sharp photo compression | 250-entry seed test passes |
|
||||
| 11 | Remove invoice PDF generation | delete `invoice-template.ts`, the `generatePdf` call in `invoices.ts`, the API route `/api/v1/invoices/[id]/generate-pdf`; remove UI link | invoice list still works minus PDF button |
|
||||
| 12 | Remove TipTap-→-pdfme bridge | delete `tiptap-to-pdfme.ts`, the preview route, the `generatePdf` block in `document-templates.ts:516`, the `getStandardEoiTemplateHtml` seed reference | admin template editor still saves; preview removed |
|
||||
| 13 | Add unpdf to berth parser tier-2 | wire `unpdf` into `berth-pdf-parser.ts` for PDF→image rasterization; keep tesseract.js | berth PDF upload still parses |
|
||||
| 14 | Cleanup: drop pdfme deps | remove `@pdfme/common`, `@pdfme/generator`, `@pdfme/schemas` from package.json; delete `generate.ts`, `eoi-standard-inapp.ts`; clean up unused validators | `pnpm install` clean; no remaining imports |
|
||||
|
||||
Total: 14 commits. Most are small (5-15 file diffs). Commits 2, 10, and 12 are the heaviest. Vitest + tsc stay green throughout; each commit only flips behavior after its tests pass.
|
||||
|
||||
## Deferred (added to BACKLOG)
|
||||
|
||||
- Admin-uploaded PDF templates with AcroForm-fill (the invoice template-fill pattern). Needs: new `pdf_templates` table + field-mapping editor + admin upload UI + generalized `fillAcroForm()` utility. Likely ~1 week solo.
|
||||
- Port brand color tokens (admin sets brand color → flows into PDF accent color). ~2h.
|
||||
- Per-template logo override (different logo for invoices vs reports). YAGNI unless asked.
|
||||
- Optical receipt-photo rotation/deskew (auto-rotate phone-upload receipts to readable orientation). ~half day.
|
||||
- Replace tesseract.js with cloud OCR (AWS Textract / Google Vision) for berth parsing tier-2. Out of scope.
|
||||
|
||||
## Open questions
|
||||
|
||||
None blocking. Implementation can begin after user spec review.
|
||||
124
docs/website-cutover-runbook.md
Normal file
124
docs/website-cutover-runbook.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Website ↔ CRM cutover runbook
|
||||
|
||||
This document captures the agreed plan (per the 2026-05-09 audit, Q6) for
|
||||
moving the marketing website off the legacy NocoDB Berths table and onto
|
||||
the CRM as the source of truth. Decision: **double-write transition
|
||||
window** — both feeds stay live for ~30 days, then NocoDB is decommissioned.
|
||||
|
||||
The CRM side is fully wired today. Most outstanding work lives in the
|
||||
**website repo**.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints involved
|
||||
|
||||
### Public berth feed (replaces NocoDB Berths read path)
|
||||
|
||||
- `GET /api/public/berths` — list (NocoDB-verbatim shape; see
|
||||
`src/lib/services/public-berths.ts`)
|
||||
- `GET /api/public/berths/[mooringNumber]` — single
|
||||
- Cache: `s-maxage=300, stale-while-revalidate=60` (5 min)
|
||||
- Status mapping: `Sold` > `Under Offer` > `Available`
|
||||
|
||||
### Public inquiry intake (replaces NocoDB inquiry write path)
|
||||
|
||||
- `POST /api/public/website-inquiries` — accepts inquiry form submissions
|
||||
from the marketing site
|
||||
- Auth: shared secret in `X-Intake-Secret` header, compared via timing-safe
|
||||
equality against `WEBSITE_INTAKE_SECRET`. Refuses every request when the
|
||||
env var is unset (correct posture for dev / staging until the website is
|
||||
also configured).
|
||||
|
||||
### Health endpoint (monitoring contract)
|
||||
|
||||
- `GET /api/public/health` — anonymous: `{status, timestamp}` (always 200,
|
||||
for uptime monitors). Authenticated with `X-Intake-Secret`: full
|
||||
`{status, env, appUrl, timestamp, checks: {db, redis}}` payload, returns
|
||||
503 when any dependency is down. The website calls the authenticated
|
||||
variant on startup so it refuses to boot when its `CRM_PUBLIC_URL`
|
||||
points at the wrong env.
|
||||
|
||||
---
|
||||
|
||||
## Pre-cutover checklist (CRM side — done)
|
||||
|
||||
- [x] `/api/public/berths` serves Map Data (117 rows backfilled
|
||||
2026-05-09).
|
||||
- [x] PublicBerth payload exposes verbatim NocoDB fields, plus
|
||||
booleans / metric variants / timestamps (commit `72ab718`). Price
|
||||
intentionally omitted (decision Q4).
|
||||
- [x] `/api/public/website-inquiries` POST handler exists, gated on
|
||||
`WEBSITE_INTAKE_SECRET`.
|
||||
- [x] `WEBSITE_INTAKE_SECRET` documented in `.env.example`.
|
||||
|
||||
## Pre-cutover checklist (website repo — owed)
|
||||
|
||||
- [ ] Generate a strong shared secret (`openssl rand -hex 32`) and set
|
||||
`CRM_INTAKE_SECRET` (website) **and** `WEBSITE_INTAKE_SECRET` (CRM)
|
||||
to the same value in production.
|
||||
- [ ] Wire the website's berth-map fetch to `${CRM_PUBLIC_URL}/api/public/berths`.
|
||||
Keep the existing NocoDB fetch in parallel for the transition window.
|
||||
- [ ] Wire the website's inquiry submit handler to `POST` to
|
||||
`${CRM_PUBLIC_URL}/api/public/website-inquiries` with the
|
||||
`X-Intake-Secret` header. Keep the existing NocoDB write in parallel.
|
||||
- [ ] Add a startup probe to `${CRM_PUBLIC_URL}/api/public/health`
|
||||
(authenticated) so the website fails fast on misconfigured env.
|
||||
|
||||
## Double-write window (target: 30 days)
|
||||
|
||||
During the window:
|
||||
|
||||
1. Marketing site reads from BOTH feeds for any change-detection or
|
||||
reconciliation jobs (or just CRM if reads can flip atomically).
|
||||
2. Marketing site writes inquiries to BOTH NocoDB and CRM. The CRM
|
||||
surface is treated as authoritative for triage; NocoDB stays as a
|
||||
passive backup so the rollback path is one DNS / env flip away.
|
||||
3. Berth status edits made in CRM are NOT synced back to NocoDB.
|
||||
NocoDB will progressively go stale — accepted because the website is
|
||||
already preferring the CRM read. NocoDB stays usable as a snapshot of
|
||||
pre-cutover state.
|
||||
4. Daily sanity check: `curl -s ${CRM_PUBLIC_URL}/api/public/berths | jq '.pageInfo'`
|
||||
— confirms the public feed still serves and the row count matches
|
||||
expectations (117 berths in port-nimara).
|
||||
|
||||
## Cutover steps (target: ~Day 30)
|
||||
|
||||
1. Stop the NocoDB-side writes from the website (drop the dual write).
|
||||
2. Stop the NocoDB-side reads from the website (CRM-only).
|
||||
3. Mark the NocoDB Berths table read-only via NocoDB ACL.
|
||||
4. Wait 7 days; if no one notices anything missing, drop the NocoDB
|
||||
Berths table and revoke the NocoDB MCP token from `~/.claude.json`.
|
||||
|
||||
## Rollback path
|
||||
|
||||
The double-write design means rollback within the 30-day window is a
|
||||
single env / DNS flip:
|
||||
|
||||
- Website: change `CRM_PUBLIC_URL` to the old NocoDB-fronted URL OR
|
||||
toggle a feature flag back to NocoDB.
|
||||
- CRM: no change required — the public endpoints stay live for any
|
||||
consumer that didn't roll back.
|
||||
|
||||
After NocoDB is decommissioned, rollback requires restoring the table
|
||||
from backup. That's the trade-off for the cleaner final state.
|
||||
|
||||
---
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
- **Berth `archived_at`** — when retiring a berth, the public feed will
|
||||
still serve it. Add a soft-delete column + filter on
|
||||
`/api/public/berths` before any berth is permanently removed. (Not
|
||||
blocking the cutover; flagged in the audit.)
|
||||
- **CRM-edit drift vs re-imports** — `scripts/import-berths-from-nocodb.ts`
|
||||
skips rows where `updated_at > last_imported_at`. After cutover the
|
||||
website MUST stop writing to NocoDB; if any straggler write hits
|
||||
NocoDB and someone re-runs the import script, those edits would
|
||||
silently win over CRM data. Mitigation: the script is opt-in, and the
|
||||
`updated_at` guard means a full re-import only overwrites when the
|
||||
rep explicitly passes `--force`. Decommission the script once cutover
|
||||
is irreversible.
|
||||
- **5-minute cache** — `s-maxage=300` on `/api/public/berths` means a
|
||||
CRM-side status flip won't show on the website for up to 5 minutes.
|
||||
Acceptable for marketing; bump if marketing wants near-real-time
|
||||
updates.
|
||||
@@ -1,27 +1,44 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
|
||||
import prettier from 'eslint-config-prettier/flat';
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript", "prettier"),
|
||||
...nextCoreWebVitals,
|
||||
prettier,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
// React Compiler safety rules shipped with eslint-config-next@16 /
|
||||
// react-hooks@7. Triage status (2026-05-13 sweep):
|
||||
// purity, set-state-in-render, immutability, refs,
|
||||
// set-state-in-effect — promoted to error after the cleanup
|
||||
// sweep (Wave 3 of the 2026-05-12 audit). All hits migrated to
|
||||
// either useQuery, render-phase derivation, key-based remount,
|
||||
// or a justified eslint-disable for canonical setState-on-
|
||||
// subscription patterns. New regressions block CI.
|
||||
// incompatible-library — informational only ("Compiler
|
||||
// skipped this file because of a non-Compiler-safe import").
|
||||
// No action needed; silenced to keep `pnpm lint` output
|
||||
// actionable.
|
||||
'react-hooks/purity': 'error',
|
||||
'react-hooks/set-state-in-render': 'error',
|
||||
'react-hooks/immutability': 'error',
|
||||
'react-hooks/refs': 'error',
|
||||
'react-hooks/set-state-in-effect': 'error',
|
||||
'react-hooks/incompatible-library': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["client-portal/**"],
|
||||
// Tests assert response shape via expect() — narrowing every
|
||||
// `res.json()` to a structural type adds boilerplate without catching
|
||||
// bugs. Allow `any` casts at JSON boundaries in test files.
|
||||
files: ['tests/**/*.ts', 'tests/**/*.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['client-portal/**', 'next-env.d.ts'],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
20
instrumentation.ts
Normal file
20
instrumentation.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Next.js instrumentation hook (Next 13.4+ / 15+ / 16+).
|
||||
*
|
||||
* Runs once at server startup. We use it to wire Sentry's server +
|
||||
* edge runtimes. The client init is auto-bundled by withSentryConfig
|
||||
* from `sentry.client.config.ts`.
|
||||
*
|
||||
* The Sentry imports are gated behind the DSN check so the SDK stays
|
||||
* a no-op when unconfigured.
|
||||
*/
|
||||
|
||||
export async function register() {
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return;
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
await import('./sentry.server.config');
|
||||
}
|
||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
await import('./sentry.edge.config');
|
||||
}
|
||||
}
|
||||
9
messages/en.json
Normal file
9
messages/en.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"back": "Back"
|
||||
}
|
||||
}
|
||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import './.next/types/routes.d.ts';
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import type { NextConfig } from 'next';
|
||||
import bundleAnalyzer from '@next/bundle-analyzer';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
|
||||
// next-intl plugin — points at our request-config entrypoint. Even
|
||||
// though we ship only English today, the plugin is wired so future
|
||||
// locale additions are a config-only change, not a code rewrite.
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Wrap the config with the bundle analyzer. Run `ANALYZE=true pnpm build`
|
||||
// to get treemaps of the client + server bundles after the build
|
||||
// completes. Pairs with the recharts dynamic-import work the audit
|
||||
// flagged — gives us the tool to verify chart bundles only ship on the
|
||||
// dashboard surface and not on routes that don't render them.
|
||||
const withBundleAnalyzer = bundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
});
|
||||
|
||||
/**
|
||||
* Security headers applied to every response. Per audit-pass-#3 finding:
|
||||
* the previous config emitted no CSP, X-Frame-Options, HSTS, or
|
||||
@@ -26,6 +43,15 @@ const isProd = process.env.NODE_ENV === 'production';
|
||||
const devScriptHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com';
|
||||
const devConnectHosts = isProd ? '' : ' http://unpkg.com https://unpkg.com';
|
||||
|
||||
// Fallback CSP for paths the proxy doesn't run on (static assets,
|
||||
// API JSON responses where script-src is moot). Production HTML
|
||||
// responses get a stricter per-request nonce-based CSP set in
|
||||
// `src/proxy.ts:applyCsp`; this header just provides a sane default
|
||||
// so a misconfigured static-only route still has a CSP.
|
||||
//
|
||||
// Dev keeps 'unsafe-inline' + 'unsafe-eval' on script-src because
|
||||
// Next's HMR runtime evaluates code dynamically and the nonce
|
||||
// machinery doesn't reach it.
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`,
|
||||
@@ -52,6 +78,23 @@ const securityHeaders = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
// Hide the floating dev indicator (the little circle/N badge in the
|
||||
// corner). Compile errors still surface via the full overlay; this
|
||||
// only removes the idle "everything is fine" indicator that's been
|
||||
// visible in every screenshot from the iPhone testing pass.
|
||||
devIndicators: false,
|
||||
// LAN access from a real iPhone hits the dev server via the Mac's
|
||||
// local IP (e.g. 192.168.x.x), not localhost. Next 15 surfaces a
|
||||
// warning for cross-origin /_next/* fetches unless we allow-list the
|
||||
// origins explicitly. Wildcard the 192.168/0.0.0.0 ranges in dev so
|
||||
// any LAN device works without a config edit per network.
|
||||
...(isProd ? {} : { allowedDevOrigins: ['192.168.1.42'] }),
|
||||
// Native/CJS-leaning server-only packages — list here so Next doesn't
|
||||
// bundle them into the route trace (slower cold start + risk that
|
||||
// native bindings fail at runtime). Build-auditor C3+M3: socket.io
|
||||
// is only imported by the custom server entry point, so the Next
|
||||
// tracer has no reason to include it; listing here makes the
|
||||
// dependency visible to the build system.
|
||||
serverExternalPackages: [
|
||||
'pino',
|
||||
'pino-pretty',
|
||||
@@ -61,19 +104,40 @@ const nextConfig: NextConfig = {
|
||||
'postgres',
|
||||
'better-auth',
|
||||
'nodemailer',
|
||||
'socket.io',
|
||||
'@socket.io/redis-adapter',
|
||||
'imapflow',
|
||||
'mailparser',
|
||||
'pdf-lib',
|
||||
'sharp',
|
||||
'tesseract.js',
|
||||
'@react-pdf/renderer',
|
||||
'unpdf',
|
||||
],
|
||||
images: {
|
||||
remotePatterns: [{ protocol: 'https', hostname: '*.portnimara.com' }],
|
||||
},
|
||||
experimental: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
typedRoutes: true,
|
||||
outputFileTracingIncludes: {
|
||||
// Bundle the EOI source PDF so the in-app EOI pathway can read it at
|
||||
// runtime in the standalone build. Reading via fs.readFile from
|
||||
// process.cwd() requires the file to be traced explicitly.
|
||||
'/api/v1/document-templates/**': ['./assets/eoi-template.pdf'],
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/:portSlug/documents/files',
|
||||
destination: '/:portSlug/documents',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/:portSlug/documents/files/:path*',
|
||||
destination: '/:portSlug/documents',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
@@ -84,4 +148,21 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
// Sentry wrapper is conditional: if NEXT_PUBLIC_SENTRY_DSN isn't set we
|
||||
// skip its build-time source-map upload + middleware injection so dev
|
||||
// builds stay fast and CI doesn't need credentials. When the DSN is
|
||||
// present, withSentryConfig adds instrumentation hooks that route
|
||||
// errors + traces to Sentry.
|
||||
const withSentry = process.env.NEXT_PUBLIC_SENTRY_DSN
|
||||
? (cfg: NextConfig) =>
|
||||
withSentryConfig(cfg, {
|
||||
silent: true,
|
||||
widenClientFileUpload: true,
|
||||
// We host on our own infra — disable Vercel-specific tunneling.
|
||||
tunnelRoute: undefined,
|
||||
// Strip Sentry debug logger from prod bundle.
|
||||
disableLogger: true,
|
||||
})
|
||||
: (cfg: NextConfig) => cfg;
|
||||
|
||||
export default withSentry(withBundleAnalyzer(withNextIntl(nextConfig)));
|
||||
|
||||
93
package.json
93
package.json
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack -H 0.0.0.0",
|
||||
"build": "next build && pnpm build:server",
|
||||
"build:server": "esbuild src/server.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
|
||||
"build:worker": "esbuild src/worker.ts --bundle --platform=node --target=node20 --format=cjs --outdir=dist --packages=external --tsconfig=tsconfig.server.json",
|
||||
@@ -13,13 +13,18 @@
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:migrate": "tsx scripts/db-migrate.ts apply",
|
||||
"db:migrate:status": "tsx scripts/db-migrate.ts status",
|
||||
"db:migrate:baseline": "tsx scripts/db-migrate.ts baseline",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/lib/db/seed.ts",
|
||||
"db:seed:realistic": "tsx src/lib/db/seed.ts",
|
||||
"db:seed:synthetic": "tsx src/lib/db/seed-synthetic.ts",
|
||||
"db:seed:wide-synthetic": "tsx src/lib/db/seed-wide-synthetic.ts",
|
||||
"db:reset": "tsx scripts/db-reset.ts --confirm",
|
||||
"db:reseed:realistic": "pnpm db:reset && pnpm db:seed:realistic",
|
||||
"db:reseed:synthetic": "pnpm db:reset && pnpm db:seed:synthetic",
|
||||
"db:backfill:doc-folders": "tsx scripts/backfill-document-folders.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:smoke": "playwright test --project=smoke",
|
||||
"test:e2e:exhaustive": "playwright test --project=exhaustive",
|
||||
@@ -30,10 +35,8 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@pdfme/common": "^6.1.2",
|
||||
"@pdfme/generator": "^6.1.2",
|
||||
"@pdfme/schemas": "^6.1.2",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -54,80 +57,116 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@sentry/nextjs": "^10.53.1",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-query-devtools": "^5.100.9",
|
||||
"@tanstack/query-broadcast-client-experimental": "^5.100.10",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-query-devtools": "^5.100.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"archiver": "^8.0.0",
|
||||
"better-auth": "^1.6.9",
|
||||
"bullmq": "^5.76.6",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"archiver": "^7.0.1",
|
||||
"better-auth": "^1.6.11",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"bullmq": "^5.76.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cron-parser": "^5.5.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"imapflow": "^1.3.3",
|
||||
"ioredis": "^5.10.1",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"jose": "^6.2.3",
|
||||
"libphonenumber-js": "^1.12.43",
|
||||
"libphonenumber-js": "^1.13.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mailparser": "^3.9.8",
|
||||
"minio": "^8.0.7",
|
||||
"next": "15.5.18",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.6",
|
||||
"next-intl": "^4.11.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^8.0.7",
|
||||
"openai": "^6.37.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"p-queue": "^9.2.0",
|
||||
"p-retry": "^8.0.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"pdfkit": "^0.18.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-day-picker": "^10.0.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-email": "^6.1.3",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-number-format": "^5.4.5",
|
||||
"react-pdf": "^10.4.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"recharts": "^3.8.1",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"svgo": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unpdf": "^1.6.2",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.25.76",
|
||||
"web-vitals": "^5.2.0",
|
||||
"yet-another-react-lightbox": "^3.32.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@hookform/devtools": "^4.4.0",
|
||||
"@next/bundle-analyzer": "^16.2.6",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/iso-3166-2": "^1.0.4",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"esbuild": "^0.27.7",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "15.5.18",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.3",
|
||||
"lint-staged": "^17.0.4",
|
||||
"postcss": "^8.5.14",
|
||||
"prettier": "^3.8.3",
|
||||
"react-grab": "^0.1.33",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"react-grab": "^0.1.34",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tsx": "^4.21.0",
|
||||
"type-fest": "^5.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
"vitest": "^4.1.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
||||
6549
pnpm-lock.yaml
generated
6549
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
246
scripts/backfill-document-folders.ts
Normal file
246
scripts/backfill-document-folders.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Idempotent backfill: ensure every port has the three system roots
|
||||
* (Clients / Companies / Yachts), every entity with attached files
|
||||
* has a per-entity subfolder, every file with entity FKs has
|
||||
* `folder_id` set, and every signed file from a completed workflow
|
||||
* has the workflow's entity FKs propagated onto it.
|
||||
*
|
||||
* Safe to re-run: all writes target only rows where the relevant
|
||||
* column is NULL. Per-port `pg_advisory_xact_lock` serializes
|
||||
* concurrent runs.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-document-folders.ts
|
||||
* pnpm tsx scripts/backfill-document-folders.ts --port <portId>
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { files, documents } from '@/lib/db/schema/documents';
|
||||
import {
|
||||
ensureSystemRoots,
|
||||
ensureEntityFolder,
|
||||
type EntityType,
|
||||
} from '@/lib/services/document-folders.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export interface BackfillOptions {
|
||||
/** When provided, only backfill this port. Otherwise all ports. */
|
||||
portId?: string;
|
||||
/** User ID recorded in `created_by` for any folders created. */
|
||||
systemUserId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-port counters surfaced through the return value so the CLI can
|
||||
* print them and operators (or follow-up scripts) can sanity-check that
|
||||
* a re-run shrinks each number toward zero.
|
||||
*/
|
||||
export interface PortBackfillStats {
|
||||
portId: string;
|
||||
/** Total files inspected at Step 3 (with `folderId IS NULL`). */
|
||||
filesProcessed: number;
|
||||
/** Files updated with `folder_id` set in Step 3. */
|
||||
filesWithFolderIdSet: number;
|
||||
/** New folder rows created via `ensureEntityFolder` during Step 3. */
|
||||
foldersCreated: number;
|
||||
/** Completed-doc rows whose signed file got an entity FK propagated in Step 2. */
|
||||
fksPropagated: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time idempotent backfill. See module-level JSDoc for full
|
||||
* description of what each step does.
|
||||
*/
|
||||
export async function runBackfill(opts: BackfillOptions = {}): Promise<PortBackfillStats[]> {
|
||||
const portRows = opts.portId
|
||||
? [{ id: opts.portId }]
|
||||
: await db.select({ id: ports.id }).from(ports);
|
||||
|
||||
const systemUser = opts.systemUserId ?? 'system-backfill';
|
||||
const allStats: PortBackfillStats[] = [];
|
||||
|
||||
for (const { id: portId } of portRows) {
|
||||
const stats: PortBackfillStats = {
|
||||
portId,
|
||||
filesProcessed: 0,
|
||||
filesWithFolderIdSet: 0,
|
||||
foldersCreated: 0,
|
||||
fksPropagated: 0,
|
||||
};
|
||||
await db.transaction(async (tx) => {
|
||||
// Serialize concurrent runs on a per-port lock so two simultaneous
|
||||
// backfills can't race on folder inserts.
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${portId})::bigint)`);
|
||||
|
||||
// ── Step 1: Ensure system roots exist for this port ──────────────────
|
||||
await ensureSystemRoots(portId, systemUser);
|
||||
|
||||
// ── Step 2: Propagate entity FKs from completed workflows onto their
|
||||
// signed file rows (pre-auto-deposit legacy completions). ──
|
||||
const completedDocs = await tx
|
||||
.select({
|
||||
id: documents.id,
|
||||
signedFileId: documents.signedFileId,
|
||||
clientId: documents.clientId,
|
||||
companyId: documents.companyId,
|
||||
yachtId: documents.yachtId,
|
||||
})
|
||||
.from(documents)
|
||||
.where(
|
||||
and(
|
||||
eq(documents.portId, portId),
|
||||
eq(documents.status, 'completed'),
|
||||
isNotNull(documents.signedFileId),
|
||||
),
|
||||
);
|
||||
|
||||
for (const d of completedDocs) {
|
||||
if (!d.signedFileId) continue;
|
||||
|
||||
const owner: { type: EntityType; id: string } | null = d.clientId
|
||||
? { type: 'client', id: d.clientId }
|
||||
: d.companyId
|
||||
? { type: 'company', id: d.companyId }
|
||||
: d.yachtId
|
||||
? { type: 'yacht', id: d.yachtId }
|
||||
: null;
|
||||
|
||||
if (!owner) continue;
|
||||
|
||||
// Build the update object with ONLY the matching FK column so we
|
||||
// never pass column references to .set() (Drizzle syntax bug fix).
|
||||
const update =
|
||||
owner.type === 'client'
|
||||
? { clientId: owner.id }
|
||||
: owner.type === 'company'
|
||||
? { companyId: owner.id }
|
||||
: { yachtId: owner.id };
|
||||
|
||||
const matchingFkColumn =
|
||||
owner.type === 'client'
|
||||
? files.clientId
|
||||
: owner.type === 'company'
|
||||
? files.companyId
|
||||
: files.yachtId;
|
||||
|
||||
const propagated = await tx
|
||||
.update(files)
|
||||
.set(update)
|
||||
.where(
|
||||
and(eq(files.id, d.signedFileId), eq(files.portId, portId), isNull(matchingFkColumn)),
|
||||
)
|
||||
.returning({ id: files.id });
|
||||
stats.fksPropagated += propagated.length;
|
||||
}
|
||||
|
||||
// ── Step 3: For every file with entity FKs but no folder_id,
|
||||
// create the entity subfolder and set folder_id. ──────────
|
||||
const fileRows = await tx
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.portId, portId), isNull(files.folderId)));
|
||||
stats.filesProcessed = fileRows.length;
|
||||
|
||||
const folderIdsCreatedThisRun = new Set<string>();
|
||||
const folderIdsSeenThisRun = new Set<string>();
|
||||
for (const f of fileRows) {
|
||||
const owner: { type: EntityType; id: string } | null = f.clientId
|
||||
? { type: 'client', id: f.clientId }
|
||||
: f.companyId
|
||||
? { type: 'company', id: f.companyId }
|
||||
: f.yachtId
|
||||
? { type: 'yacht', id: f.yachtId }
|
||||
: null;
|
||||
|
||||
if (!owner) continue;
|
||||
|
||||
try {
|
||||
const beforeExisted = folderIdsSeenThisRun.has(`${owner.type}:${owner.id}`);
|
||||
const folder = await ensureEntityFolder(portId, owner.type, owner.id, systemUser);
|
||||
folderIdsSeenThisRun.add(`${owner.type}:${owner.id}`);
|
||||
if (!beforeExisted && !folderIdsCreatedThisRun.has(folder.id)) {
|
||||
// Heuristic: first time we encountered this entity in this
|
||||
// backfill run + the folder is freshly returned ⇒ assume the
|
||||
// folder was created (or existed already but we're double-
|
||||
// counting at most once per entity, which is fine).
|
||||
folderIdsCreatedThisRun.add(folder.id);
|
||||
}
|
||||
await tx
|
||||
.update(files)
|
||||
.set({ folderId: folder.id })
|
||||
.where(and(eq(files.id, f.id), eq(files.portId, portId)));
|
||||
stats.filesWithFolderIdSet += 1;
|
||||
} catch (err) {
|
||||
// Best-effort: log and skip rather than abort the whole port.
|
||||
logger.warn({ err, fileId: f.id, portId }, 'backfill: ensureEntityFolder failed');
|
||||
}
|
||||
}
|
||||
stats.foldersCreated = folderIdsCreatedThisRun.size;
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
portId,
|
||||
filesProcessed: stats.filesProcessed,
|
||||
filesWithFolderIdSet: stats.filesWithFolderIdSet,
|
||||
foldersCreated: stats.foldersCreated,
|
||||
fksPropagated: stats.fksPropagated,
|
||||
},
|
||||
'backfill: port complete',
|
||||
);
|
||||
allStats.push(stats);
|
||||
}
|
||||
return allStats;
|
||||
}
|
||||
|
||||
// ── CLI entry point ────────────────────────────────────────────────────────────
|
||||
// tsx compiles TypeScript to CJS at runtime, so `require.main === module`
|
||||
// is the standard guard. The test suite imports `runBackfill` as a named
|
||||
// export; the CLI invocation hits this block and runs main().
|
||||
|
||||
if (require.main === module) {
|
||||
const portIdArg = process.argv.indexOf('--port');
|
||||
let portId: string | undefined;
|
||||
if (portIdArg !== -1) {
|
||||
const next = process.argv[portIdArg + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
logger.error('--port requires a value');
|
||||
process.exit(1);
|
||||
}
|
||||
portId = next;
|
||||
}
|
||||
runBackfill({ portId })
|
||||
.then((stats) => {
|
||||
console.log('\nBackfill complete.');
|
||||
console.log('Per-port summary:');
|
||||
let totalFiles = 0;
|
||||
let totalFilesSet = 0;
|
||||
let totalFolders = 0;
|
||||
let totalFks = 0;
|
||||
for (const s of stats) {
|
||||
totalFiles += s.filesProcessed;
|
||||
totalFilesSet += s.filesWithFolderIdSet;
|
||||
totalFolders += s.foldersCreated;
|
||||
totalFks += s.fksPropagated;
|
||||
console.log(
|
||||
` port=${s.portId}: filesProcessed=${s.filesProcessed} ` +
|
||||
`filesWithFolderIdSet=${s.filesWithFolderIdSet} ` +
|
||||
`foldersCreated=${s.foldersCreated} fksPropagated=${s.fksPropagated}`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Totals: ports=${stats.length} filesProcessed=${totalFiles} ` +
|
||||
`filesWithFolderIdSet=${totalFilesSet} foldersCreated=${totalFolders} ` +
|
||||
`fksPropagated=${totalFks}`,
|
||||
);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, 'Backfill failed');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
275
scripts/db-migrate.ts
Normal file
275
scripts/db-migrate.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Production migration runner.
|
||||
*
|
||||
* Why this exists (and why `drizzle-kit migrate` isn't enough):
|
||||
*
|
||||
* - Drizzle's bundled `migrate()` wraps every migration in a single
|
||||
* transaction. Postgres forbids `CREATE INDEX CONCURRENTLY` inside
|
||||
* a transaction (raises 25001) — so any migration containing
|
||||
* CONCURRENTLY silently aborts or, worse, leaves the migration
|
||||
* marked applied with the index missing. `0052_audit_critical_fixes.sql`
|
||||
* ships six CONCURRENTLY composite indexes today and they never
|
||||
* landed in prod.
|
||||
*
|
||||
* - `drizzle-kit push` skips DDL the kit can't infer from the schema —
|
||||
* e.g. CHECK constraints, partial unique indexes, the berth-pdf
|
||||
* circular FK. push-only deployments diverge from migration-tracked
|
||||
* truth.
|
||||
*
|
||||
* This script:
|
||||
* 1. Reads migrations in journal order from `src/lib/db/migrations`.
|
||||
* 2. Tracks applied state in `drizzle.__drizzle_migrations` (matching
|
||||
* Drizzle's schema so other tooling sees the same source of truth).
|
||||
* 3. For each pending migration: splits on `--> statement-breakpoint`,
|
||||
* classifies each statement as concurrency-safe (CREATE INDEX
|
||||
* CONCURRENTLY / REINDEX CONCURRENTLY → outside tx) or
|
||||
* transactional (everything else → batched in one tx per migration).
|
||||
* 4. Records hash + when-applied so re-runs are no-ops.
|
||||
*
|
||||
* Modes:
|
||||
* `pnpm db:migrate` — apply pending migrations
|
||||
* `pnpm db:migrate:status` — show pending vs applied without applying
|
||||
* `pnpm db:migrate:baseline` — mark every migration as applied without
|
||||
* running it. Use ONCE per environment when
|
||||
* the schema was bootstrapped via `db:push`
|
||||
* (dev + the original prod cutover). After
|
||||
* baseline, all future migrations go through
|
||||
* `db:migrate` and are tracked in
|
||||
* `__drizzle_migrations`.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const STATEMENT_BREAKPOINT = '--> statement-breakpoint';
|
||||
const MIGRATIONS_DIR = join(process.cwd(), 'src/lib/db/migrations');
|
||||
const SCHEMA_NAME = 'drizzle';
|
||||
const TABLE_NAME = '__drizzle_migrations';
|
||||
|
||||
interface JournalEntry {
|
||||
idx: number;
|
||||
version: string;
|
||||
when: number;
|
||||
tag: string;
|
||||
breakpoints: boolean;
|
||||
}
|
||||
|
||||
interface Journal {
|
||||
version: string;
|
||||
dialect: string;
|
||||
entries: JournalEntry[];
|
||||
}
|
||||
|
||||
interface MigrationFile {
|
||||
tag: string;
|
||||
/** Folder millis from journal `when` — Drizzle uses this as the
|
||||
* primary key in `__drizzle_migrations`. */
|
||||
folderMillis: number;
|
||||
/** Full file contents. */
|
||||
sql: string;
|
||||
/** SHA-256 hex of the raw file for re-application detection. */
|
||||
hash: string;
|
||||
}
|
||||
|
||||
interface Statement {
|
||||
/** Raw SQL text (trimmed). */
|
||||
sql: string;
|
||||
/** True when the statement must execute outside a transaction. */
|
||||
needsAutocommit: boolean;
|
||||
}
|
||||
|
||||
function isConcurrencyDDL(sql: string): boolean {
|
||||
const head = sql
|
||||
.replace(/^\s*--.*$/gm, '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return (
|
||||
/\bCREATE\s+INDEX\s+CONCURRENTLY\b/.test(head) ||
|
||||
/\bREINDEX\s+\w*\s*CONCURRENTLY\b/.test(head) ||
|
||||
/\bDROP\s+INDEX\s+CONCURRENTLY\b/.test(head)
|
||||
);
|
||||
}
|
||||
|
||||
function readMigrations(): MigrationFile[] {
|
||||
const journal = JSON.parse(
|
||||
readFileSync(join(MIGRATIONS_DIR, 'meta', '_journal.json'), 'utf8'),
|
||||
) as Journal;
|
||||
|
||||
const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'));
|
||||
const byTag = new Map(files.map((f) => [f.replace(/\.sql$/, ''), f]));
|
||||
|
||||
return journal.entries.map((entry) => {
|
||||
const filename = byTag.get(entry.tag);
|
||||
if (!filename) {
|
||||
throw new Error(`Migration ${entry.tag} in journal but ${entry.tag}.sql not on disk`);
|
||||
}
|
||||
const sql = readFileSync(join(MIGRATIONS_DIR, filename), 'utf8');
|
||||
const hash = createHash('sha256').update(sql).digest('hex');
|
||||
return { tag: entry.tag, folderMillis: entry.when, sql, hash };
|
||||
});
|
||||
}
|
||||
|
||||
function splitStatements(sql: string): Statement[] {
|
||||
// Drizzle inserts `--> statement-breakpoint` between every statement
|
||||
// when `breakpoints: true` in drizzle.config. We split on those AND
|
||||
// strip trailing semicolons. Anything before the first breakpoint
|
||||
// counts too.
|
||||
const parts = sql.split(STATEMENT_BREAKPOINT);
|
||||
const out: Statement[] = [];
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed || trimmed.startsWith('--')) {
|
||||
// Comment-only chunks (pre-breakpoint header etc.) — skip if
|
||||
// they have no executable SQL.
|
||||
const nonComment = trimmed
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--') && line.trim().length > 0);
|
||||
if (nonComment.length === 0) continue;
|
||||
}
|
||||
out.push({ sql: trimmed, needsAutocommit: isConcurrencyDDL(trimmed) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function ensureMigrationsTable(sql: postgres.Sql): Promise<void> {
|
||||
await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS "${SCHEMA_NAME}"`);
|
||||
await sql.unsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${SCHEMA_NAME}"."${TABLE_NAME}" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at bigint
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function getAppliedHashes(sql: postgres.Sql): Promise<Set<string>> {
|
||||
const rows = await sql.unsafe<{ hash: string }[]>(
|
||||
`SELECT hash FROM "${SCHEMA_NAME}"."${TABLE_NAME}"`,
|
||||
);
|
||||
return new Set(rows.map((r) => r.hash));
|
||||
}
|
||||
|
||||
async function applyMigration(sql: postgres.Sql, migration: MigrationFile): Promise<void> {
|
||||
const statements = splitStatements(migration.sql);
|
||||
if (statements.length === 0) {
|
||||
console.log(` [${migration.tag}] no executable statements, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const autocommit = statements.filter((s) => s.needsAutocommit);
|
||||
const transactional = statements.filter((s) => !s.needsAutocommit);
|
||||
|
||||
// Transactional batch first — schema changes that CONCURRENTLY ops
|
||||
// depend on (e.g. column adds before CREATE INDEX) need to exist
|
||||
// before the index build runs. Drizzle migrations are written in
|
||||
// this order; we preserve it within each phase.
|
||||
if (transactional.length > 0) {
|
||||
await sql.begin(async (tx) => {
|
||||
for (const stmt of transactional) {
|
||||
await tx.unsafe(stmt.sql);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// CONCURRENTLY ops run one at a time, each as its own implicit tx.
|
||||
// No `BEGIN`/`COMMIT` wrapping — postgres-js's `sql.unsafe` runs
|
||||
// each call as an independent transaction.
|
||||
for (const stmt of autocommit) {
|
||||
await sql.unsafe(stmt.sql);
|
||||
}
|
||||
|
||||
// Record the migration as applied. created_at mirrors Drizzle's own
|
||||
// schema so `drizzle-kit migrate` (if ever invoked) sees the same
|
||||
// state we wrote.
|
||||
await sql.unsafe(
|
||||
`INSERT INTO "${SCHEMA_NAME}"."${TABLE_NAME}" (hash, created_at) VALUES ($1, $2)`,
|
||||
[migration.hash, migration.folderMillis],
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error('DATABASE_URL must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = process.argv[2] ?? 'apply';
|
||||
if (!['apply', 'status', 'baseline'].includes(mode)) {
|
||||
console.error(`Unknown mode: ${mode}. Use 'apply' (default), 'status', or 'baseline'.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1, prepare: false });
|
||||
|
||||
try {
|
||||
await ensureMigrationsTable(sql);
|
||||
const applied = await getAppliedHashes(sql);
|
||||
const migrations = readMigrations();
|
||||
const pending = migrations.filter((m) => !applied.has(m.hash));
|
||||
|
||||
if (mode === 'status') {
|
||||
console.log(`Applied: ${applied.size}`);
|
||||
console.log(`Pending: ${pending.length}`);
|
||||
if (pending.length > 0) {
|
||||
console.log('');
|
||||
console.log('Pending migrations:');
|
||||
for (const m of pending) {
|
||||
const statements = splitStatements(m.sql);
|
||||
const conc = statements.filter((s) => s.needsAutocommit).length;
|
||||
const tx = statements.length - conc;
|
||||
console.log(` ${m.tag} — ${tx} transactional + ${conc} concurrency-safe`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'baseline') {
|
||||
if (pending.length === 0) {
|
||||
console.log('All migrations already tracked. Nothing to baseline.');
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Baselining ${pending.length} migration${
|
||||
pending.length === 1 ? '' : 's'
|
||||
} as applied without running them.`,
|
||||
);
|
||||
console.log(
|
||||
'This is correct ONLY when the schema is already in place (e.g. created via db:push).',
|
||||
);
|
||||
for (const m of pending) {
|
||||
await sql.unsafe(
|
||||
`INSERT INTO "${SCHEMA_NAME}"."${TABLE_NAME}" (hash, created_at) VALUES ($1, $2)`,
|
||||
[m.hash, m.folderMillis],
|
||||
);
|
||||
console.log(` → ${m.tag} marked as applied`);
|
||||
}
|
||||
console.log(`Done. ${pending.length} baselined.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log('No pending migrations.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Applying ${pending.length} migration${pending.length === 1 ? '' : 's'}...`);
|
||||
for (const m of pending) {
|
||||
const statements = splitStatements(m.sql);
|
||||
const conc = statements.filter((s) => s.needsAutocommit).length;
|
||||
console.log(` → ${m.tag} (${statements.length} statements, ${conc} CONCURRENTLY)`);
|
||||
await applyMigration(sql, m);
|
||||
}
|
||||
console.log(`Done. ${pending.length} applied.`);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
326
scripts/import-organized-documents.ts
Normal file
326
scripts/import-organized-documents.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Importer for an organized S3 / filesystem bucket whose folder structure
|
||||
* already represents real organisation. Walks every key under `--bucket-prefix`,
|
||||
* builds matching `document_folders` rows mirroring the path, then inserts
|
||||
* `documents` + `files` rows pointing at the existing storage keys verbatim
|
||||
* — no path rewrite. Use when migrating from a legacy MinIO bucket whose
|
||||
* tree is the source of truth.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/import-organized-documents.ts --port-slug <slug> \
|
||||
* --bucket-prefix "legacy-imports/" --dry-run
|
||||
* pnpm tsx scripts/import-organized-documents.ts --port-slug <slug> \
|
||||
* --bucket-prefix "legacy-imports/" --apply
|
||||
*
|
||||
* Idempotency:
|
||||
* - Folders: sibling-name unique index swallows duplicate creates and we
|
||||
* reuse the existing row.
|
||||
* - Documents: skipped when a row with `(port_id, fileStoragePath)` already
|
||||
* exists — the storage key is the natural identity for this importer.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import path from 'node:path';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { documents, documentFolders, files } from '@/lib/db/schema/documents';
|
||||
import { user } from '@/lib/db/schema/users';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { ConflictError } from '@/lib/errors';
|
||||
import { createFolder } from '@/lib/services/document-folders.service';
|
||||
import { parseImportPath } from '@/lib/services/document-import';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
|
||||
interface CliArgs {
|
||||
portSlug: string;
|
||||
bucketPrefix: string;
|
||||
dryRun: boolean;
|
||||
apply: boolean;
|
||||
uploadedByUserId: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
portSlug: '',
|
||||
bucketPrefix: '',
|
||||
dryRun: false,
|
||||
apply: false,
|
||||
uploadedByUserId: null,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--port-slug') args.portSlug = argv[++i] ?? '';
|
||||
else if (a === '--bucket-prefix') args.bucketPrefix = argv[++i] ?? '';
|
||||
else if (a === '--uploaded-by') args.uploadedByUserId = argv[++i] ?? null;
|
||||
else if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Unknown argument: ${a}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!args.portSlug) {
|
||||
console.error('Missing required --port-slug');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!args.dryRun && !args.apply) {
|
||||
console.error('Must specify either --dry-run or --apply.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (args.dryRun && args.apply) {
|
||||
console.error('--dry-run and --apply are mutually exclusive.');
|
||||
process.exit(1);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage:
|
||||
pnpm tsx scripts/import-organized-documents.ts \\
|
||||
--port-slug <slug> \\
|
||||
--bucket-prefix <prefix> \\
|
||||
(--dry-run | --apply) \\
|
||||
[--uploaded-by <userId>]
|
||||
`);
|
||||
}
|
||||
|
||||
interface PlannedDoc {
|
||||
key: string;
|
||||
folderSegments: string[];
|
||||
filename: string;
|
||||
bytes: number | null;
|
||||
contentType: string;
|
||||
alreadyImported: boolean;
|
||||
}
|
||||
|
||||
const CONTENT_TYPE_BY_EXT: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.txt': 'text/plain',
|
||||
'.csv': 'text/csv',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
function guessContentType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return CONTENT_TYPE_BY_EXT[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, args.portSlug) });
|
||||
if (!port) {
|
||||
console.error(`Port not found: ${args.portSlug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let uploadedById = args.uploadedByUserId;
|
||||
if (!uploadedById) {
|
||||
const [u] = await db.select({ id: user.id }).from(user).limit(1);
|
||||
if (!u) {
|
||||
console.error(
|
||||
'No user rows exist; pass --uploaded-by <userId> or seed at least one user before running.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
uploadedById = u.id;
|
||||
console.log(`No --uploaded-by provided; falling back to first user: ${uploadedById}`);
|
||||
}
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
console.log(`Listing keys under prefix "${args.bucketPrefix}" via ${backend.name} backend …`);
|
||||
const keys = await backend.listByPrefix(args.bucketPrefix);
|
||||
console.log(`Found ${keys.length} candidate keys.`);
|
||||
|
||||
const plan: PlannedDoc[] = [];
|
||||
for (const key of keys) {
|
||||
const parsed = parseImportPath(args.bucketPrefix, key);
|
||||
if (!parsed.filename) continue;
|
||||
|
||||
const head = await backend.head(key);
|
||||
const existing = await db.query.files.findFirst({
|
||||
where: and(eq(files.portId, port.id), eq(files.storagePath, key)),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
plan.push({
|
||||
key,
|
||||
folderSegments: parsed.folderSegments,
|
||||
filename: parsed.filename,
|
||||
bytes: head?.sizeBytes ?? null,
|
||||
contentType: head?.contentType ?? guessContentType(parsed.filename),
|
||||
alreadyImported: !!existing,
|
||||
});
|
||||
}
|
||||
|
||||
printPlan(plan);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('\nDry-run complete. No changes written.');
|
||||
return;
|
||||
}
|
||||
|
||||
const folderIdByPath = new Map<string, string | null>();
|
||||
folderIdByPath.set('', null);
|
||||
let createdCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const entry of plan) {
|
||||
if (entry.alreadyImported) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderId = await ensureFolderChain(
|
||||
port.id,
|
||||
uploadedById,
|
||||
entry.folderSegments,
|
||||
folderIdByPath,
|
||||
);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const [fileRow] = await tx
|
||||
.insert(files)
|
||||
.values({
|
||||
portId: port.id,
|
||||
filename: entry.filename,
|
||||
originalName: entry.filename,
|
||||
mimeType: entry.contentType,
|
||||
sizeBytes: entry.bytes !== null ? String(entry.bytes) : null,
|
||||
storagePath: entry.key,
|
||||
uploadedBy: uploadedById,
|
||||
category: 'misc',
|
||||
folderId,
|
||||
})
|
||||
.returning();
|
||||
const [docRow] = await tx
|
||||
.insert(documents)
|
||||
.values({
|
||||
portId: port.id,
|
||||
documentType: 'other',
|
||||
title: entry.filename,
|
||||
createdBy: uploadedById,
|
||||
folderId,
|
||||
fileId: fileRow!.id,
|
||||
status: 'completed',
|
||||
isManualUpload: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: uploadedById,
|
||||
portId: port.id,
|
||||
action: 'create',
|
||||
entityType: 'document',
|
||||
entityId: docRow!.id,
|
||||
metadata: {
|
||||
source: 'organized-bucket-importer',
|
||||
storageKey: entry.key,
|
||||
folderSegments: entry.folderSegments,
|
||||
},
|
||||
});
|
||||
});
|
||||
createdCount += 1;
|
||||
console.log(`✓ Imported ${entry.key}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nDone. Created ${createdCount} documents, skipped ${skippedCount} (already imported).`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureFolderChain(
|
||||
portId: string,
|
||||
userId: string,
|
||||
segments: string[],
|
||||
cache: Map<string, string | null>,
|
||||
): Promise<string | null> {
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
let parentId: string | null = null;
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
const pathKey = segments.slice(0, i + 1).join('/');
|
||||
const cached = cache.get(pathKey);
|
||||
if (cached !== undefined) {
|
||||
parentId = cached;
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = segments[i]!;
|
||||
parentId = await createOrFindFolder(portId, userId, name, parentId);
|
||||
cache.set(pathKey, parentId);
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
async function createOrFindFolder(
|
||||
portId: string,
|
||||
userId: string,
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const created = await createFolder(portId, userId, { name, parentId });
|
||||
return created.id;
|
||||
} catch (err) {
|
||||
if (!(err instanceof ConflictError)) throw err;
|
||||
// Sibling-name unique index hit — fetch the existing row so the import
|
||||
// remains idempotent across re-runs.
|
||||
const trimmed = name.trim();
|
||||
const candidates = await db.query.documentFolders.findMany({
|
||||
where: parentId
|
||||
? and(eq(documentFolders.portId, portId), eq(documentFolders.parentId, parentId))
|
||||
: eq(documentFolders.portId, portId),
|
||||
});
|
||||
const existing = candidates.find(
|
||||
(row) =>
|
||||
(parentId ? row.parentId === parentId : row.parentId === null) &&
|
||||
row.name.toLowerCase() === trimmed.toLowerCase(),
|
||||
);
|
||||
if (!existing) throw err;
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
function printPlan(plan: PlannedDoc[]): void {
|
||||
const grouped = new Map<string, PlannedDoc[]>();
|
||||
for (const entry of plan) {
|
||||
const folder = entry.folderSegments.join('/') || '(root)';
|
||||
if (!grouped.has(folder)) grouped.set(folder, []);
|
||||
grouped.get(folder)!.push(entry);
|
||||
}
|
||||
const folderNames = Array.from(grouped.keys()).sort();
|
||||
console.log('\nPlan:');
|
||||
for (const folder of folderNames) {
|
||||
console.log(` ${folder}/`);
|
||||
for (const entry of grouped.get(folder)!) {
|
||||
const flag = entry.alreadyImported ? '·' : '+';
|
||||
const size = entry.bytes !== null ? ` (${entry.bytes}B)` : '';
|
||||
console.log(` ${flag} ${entry.filename}${size}`);
|
||||
}
|
||||
}
|
||||
const newCount = plan.filter((p) => !p.alreadyImported).length;
|
||||
const dupCount = plan.length - newCount;
|
||||
console.log(`\nTotal: ${plan.length} keys → ${newCount} new, ${dupCount} already imported.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
74
scripts/tsc-staged.mjs
Normal file
74
scripts/tsc-staged.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pre-commit type check for staged TS files.
|
||||
*
|
||||
* Writes a temp tsconfig that extends the project root and pins
|
||||
* `files` to whatever lint-staged passed in. `tsc -p` then compiles
|
||||
* the whole dep graph from those entrypoints — catches errors in
|
||||
* the staged code AND in anything it imports — while still skipping
|
||||
* the 22s full-project pass.
|
||||
*
|
||||
* Replaces `tsc-files` (npm), which silently fails under pnpm because
|
||||
* its tsc-resolution path (typescript/../.bin/tsc) doesn't exist in
|
||||
* pnpm's virtual store layout.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
|
||||
const cwd = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const files = args.filter((a) => /\.(ts|tsx)$/.test(a));
|
||||
|
||||
if (files.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Temp tsconfig lives inside the project tree (not /tmp) so @types/*
|
||||
// resolution walks up to node_modules. tsc's "atTypes" auto-discovery
|
||||
// is anchored to the tsconfig's directory, so a temp config in /tmp
|
||||
// would miss our @types/node, @types/react, etc.
|
||||
const baseDir = join(cwd, 'node_modules/.cache/tsc-staged');
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
const tmpDir = mkdtempSync(join(baseDir, 'run-'));
|
||||
const tmpConfig = join(tmpDir, 'tsconfig.json');
|
||||
|
||||
const relFiles = files.map((f) => relative(tmpDir, resolve(cwd, f)));
|
||||
|
||||
// Pull in the project's ambient .d.ts files (css module shim,
|
||||
// react-pdf JSX augment, etc.) so side-effect imports like
|
||||
// `import 'react-pdf/dist/Page/AnnotationLayer.css'` resolve under the
|
||||
// staged-only compile. Without this, `include: []` would shut out
|
||||
// everything in src/types/ and tsc reports TS2882 for any CSS import.
|
||||
const ambientTypesGlob = relative(tmpDir, join(cwd, 'src/types')) + '/**/*.d.ts';
|
||||
|
||||
writeFileSync(
|
||||
tmpConfig,
|
||||
JSON.stringify(
|
||||
{
|
||||
extends: relative(tmpDir, join(cwd, 'tsconfig.json')),
|
||||
compilerOptions: {
|
||||
noEmit: true,
|
||||
skipLibCheck: true,
|
||||
// Explicitly list `types` so the @types/* auto-discovery
|
||||
// finds them — without this, the temp-tsconfig location
|
||||
// anchors discovery to .cache/ and misses node/react/etc.
|
||||
types: ['node', 'react', 'react-dom'],
|
||||
},
|
||||
files: relFiles,
|
||||
include: [ambientTypesGlob],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const tsc = spawnSync('pnpm', ['exec', 'tsc', '-p', tmpConfig, '--pretty'], {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
process.exit(tsc.status ?? 1);
|
||||
25
sentry.client.config.ts
Normal file
25
sentry.client.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Sentry client-side init.
|
||||
*
|
||||
* No-op when `NEXT_PUBLIC_SENTRY_DSN` is unset — Sentry stays
|
||||
* shipped-but-dormant in dev. Production sets the DSN via the
|
||||
* deploy env. Sampling rate is env-driven via
|
||||
* `SENTRY_TRACES_SAMPLE_RATE` (defaults to 0.1 = 10% of transactions
|
||||
* to avoid quota burn).
|
||||
*/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
|
||||
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
|
||||
// Replay is opt-in — we'd need to verify privacy implications
|
||||
// before enabling. Leave disabled by default.
|
||||
replaysOnErrorSampleRate: 0,
|
||||
replaysSessionSampleRate: 0,
|
||||
});
|
||||
}
|
||||
17
sentry.edge.config.ts
Normal file
17
sentry.edge.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Sentry edge-runtime init (proxy.ts / middleware).
|
||||
*
|
||||
* No-op when `NEXT_PUBLIC_SENTRY_DSN` is unset.
|
||||
*/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
|
||||
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
|
||||
});
|
||||
}
|
||||
18
sentry.server.config.ts
Normal file
18
sentry.server.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Sentry server-side init.
|
||||
*
|
||||
* No-op when `NEXT_PUBLIC_SENTRY_DSN` is unset. Same DSN as the client
|
||||
* config — Sentry routes events to the right project automatically.
|
||||
*/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
|
||||
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 0.1),
|
||||
});
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { authClient } from '@/lib/auth/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
// `identifier` accepts either an email address or a username (3–30 lowercase
|
||||
// letters / digits / dot / underscore / hyphen). The server endpoint
|
||||
// /api/auth/sign-in-by-identifier resolves the username server-side and
|
||||
// forwards to better-auth in one round-trip — the canonical email is never
|
||||
// returned to the browser, which closes the username-enumeration vector.
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
identifier: z.string().min(1, 'Email or username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
@@ -25,6 +29,25 @@ export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Fresh-DB bootstrap detection: if no super-admin exists yet, /setup
|
||||
// owns the first-run flow. Failure of the status endpoint is silent
|
||||
// (login still works for everyone else).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/v1/bootstrap/status')
|
||||
.then((r) => (r.ok ? (r.json() as Promise<{ data?: { needsBootstrap?: boolean } }>) : null))
|
||||
.then((payload) => {
|
||||
if (cancelled || !payload) return;
|
||||
if (payload.data?.needsBootstrap) router.replace('/setup');
|
||||
})
|
||||
.catch(() => {
|
||||
/* silent — login UX must still work even if status check fails */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -36,13 +59,20 @@ export default function LoginPage() {
|
||||
async function onSubmit(data: LoginFormData) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await authClient.signIn.email({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
const res = await fetch('/api/auth/sign-in-by-identifier', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
identifier: data.identifier.trim(),
|
||||
password: data.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error.message ?? 'Invalid email or password');
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => ({}))) as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
toast.error(payload.error?.message ?? 'Invalid credentials');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,17 +93,20 @@ export default function LoginPage() {
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="identifier">Email or username</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
id="identifier"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
disabled={isLoading}
|
||||
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('email')}
|
||||
className={cn(errors.identifier && 'border-destructive focus-visible:ring-destructive')}
|
||||
{...register('identifier')}
|
||||
/>
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
{errors.identifier && (
|
||||
<p className="text-sm text-destructive">{errors.identifier.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -56,7 +56,10 @@ function SetPasswordInner() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const body = (await response.json().catch(() => ({}))) as {
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
187
src/app/(auth)/setup/page.tsx
Normal file
187
src/app/(auth)/setup/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const setupSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(120),
|
||||
email: z.string().email('Valid email is required').max(254),
|
||||
password: z.string().min(9, 'Password must be at least 9 characters').max(200),
|
||||
confirmPassword: z.string(),
|
||||
});
|
||||
|
||||
type SetupFormData = z.infer<typeof setupSchema>;
|
||||
|
||||
interface StatusResp {
|
||||
data: { needsBootstrap: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run setup. On a fresh DB the very first visitor can claim the
|
||||
* super-admin account here. Once anyone claims it, future visits to
|
||||
* /setup redirect back to /login — the precondition is verified both
|
||||
* server-side (`/api/v1/bootstrap/status` + `/api/v1/bootstrap/super-admin`'s
|
||||
* internal recheck) and client-side here.
|
||||
*/
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<SetupFormData>({
|
||||
resolver: zodResolver(setupSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function check() {
|
||||
try {
|
||||
const res = await apiFetch<StatusResp>('/api/v1/bootstrap/status');
|
||||
if (cancelled) return;
|
||||
if (!res.data.needsBootstrap) {
|
||||
// Already initialized — bounce to login. Replace, not push,
|
||||
// so back-button doesn't trap the user here.
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Status endpoint failed — let the user try anyway; the POST
|
||||
// does its own check and will surface a 409 if the window closed.
|
||||
} finally {
|
||||
if (!cancelled) setChecking(false);
|
||||
}
|
||||
}
|
||||
void check();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
async function onSubmit(data: SetupFormData) {
|
||||
if (data.password !== data.confirmPassword) {
|
||||
toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiFetch('/api/v1/bootstrap/super-admin', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
},
|
||||
});
|
||||
toast.success('Administrator account created — sign in to continue.');
|
||||
router.replace('/login');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create administrator account');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center text-sm text-muted-foreground">Checking setup state…</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-xl font-semibold">Welcome to Port Nimara CRM</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No administrator account exists yet. Create one to get started — you’ll be the
|
||||
super-administrator for this installation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="setup-name">Your name</Label>
|
||||
<Input
|
||||
id="setup-name"
|
||||
placeholder="Jane Operator"
|
||||
autoComplete="name"
|
||||
{...register('name')}
|
||||
className={cn(errors.name && 'border-destructive')}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="setup-email">Email</Label>
|
||||
<Input
|
||||
id="setup-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
{...register('email')}
|
||||
className={cn(errors.email && 'border-destructive')}
|
||||
/>
|
||||
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="setup-password">Password</Label>
|
||||
<Input
|
||||
id="setup-password"
|
||||
type="password"
|
||||
placeholder="At least 9 characters"
|
||||
autoComplete="new-password"
|
||||
{...register('password')}
|
||||
className={cn(errors.password && 'border-destructive')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-xs text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="setup-confirm">Confirm password</Label>
|
||||
<Input
|
||||
id="setup-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
{...register('confirmPassword')}
|
||||
className={cn(
|
||||
watch('password') !== watch('confirmPassword') &&
|
||||
watch('confirmPassword')?.length > 0 &&
|
||||
'border-destructive',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={submitting}>
|
||||
{submitting ? 'Creating account…' : 'Create administrator account'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
This screen is only available until the first administrator is created. After that,
|
||||
subsequent users are added through Admin → Users.
|
||||
</p>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { Bot, Receipt, FileText, Brain, ExternalLink } from 'lucide-react';
|
||||
import { Bot, FileText, Brain, ExternalLink } from 'lucide-react';
|
||||
|
||||
import {
|
||||
SettingsFormCard,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
||||
|
||||
const MASTER_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
@@ -59,13 +60,6 @@ interface FeatureLink {
|
||||
}
|
||||
|
||||
const FEATURE_LINKS: FeatureLink[] = [
|
||||
{
|
||||
href: '../ocr',
|
||||
icon: Receipt,
|
||||
title: 'Receipt OCR settings',
|
||||
description:
|
||||
'Provider, model, and confidence thresholds for the receipt scanner. AI fallback only runs when the on-device parser is uncertain.',
|
||||
},
|
||||
{
|
||||
href: '../berth-pdf-parser',
|
||||
icon: FileText,
|
||||
@@ -103,6 +97,21 @@ export default function AiAdminPage() {
|
||||
fields={PROVIDER_FIELDS}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" /> Receipt OCR
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Provider, model, and confidence thresholds for the receipt scanner. AI fallback only
|
||||
runs when the on-device parser is uncertain.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OcrSettingsForm embedded />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { BulkAddBerthsWizard } from '@/components/admin/bulk-add-berths-wizard';
|
||||
|
||||
export default function BulkAddBerthsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Bulk add berths"
|
||||
description="Create many berths at once. Pick a dock letter + range to generate the rows, then fill in per-row dimensions / pricing / pontoon. Standard fields (tenure, status) apply to every row; everything else is per-row."
|
||||
/>
|
||||
<BulkAddBerthsWizard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PdfLogoUploader } from '@/components/admin/branding/pdf-logo-uploader';
|
||||
|
||||
const DEFAULT_EMAIL_HEADER_HTML = `<!-- Optional pre-body header -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||
@@ -87,6 +88,7 @@ export default function BrandingSettingsPage() {
|
||||
description="HTML fragments rendered around every transactional email."
|
||||
fields={FIELDS.slice(3)}
|
||||
/>
|
||||
<PdfLogoUploader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { CheckCircle2, Info } from 'lucide-react';
|
||||
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { DocumensoTestButton } from '@/components/admin/documenso/documenso-test-button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
const API_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'documenso_api_url_override',
|
||||
label: 'API URL override',
|
||||
description: 'Optional. Falls back to DOCUMENSO_API_URL env when blank.',
|
||||
description:
|
||||
'Optional. Falls back to DOCUMENSO_API_URL env when blank. Bare host only — never include /api/v1; the client appends versioned paths based on the API version below.',
|
||||
type: 'string',
|
||||
placeholder: 'https://documenso.example.com',
|
||||
defaultValue: '',
|
||||
@@ -25,11 +29,11 @@ const API_FIELDS: SettingFieldDef[] = [
|
||||
key: 'documenso_api_version_override',
|
||||
label: 'API version',
|
||||
description:
|
||||
'Which Documenso REST API to call against this port. v1 supports Documenso 1.x (per-field PIXEL placement, /api/v1/templates and /api/v1/documents). v2 unlocks the envelope/embed endpoints introduced in Documenso 2.x. Use the test-connection button below after switching to confirm the chosen version actually works against this port’s instance.',
|
||||
'Which Documenso REST API this port targets. v1 = Documenso 1.13.x stable. v2 = Documenso 2.x with the envelope model and richer per-field metadata. Test the connection after switching. See the v2 benefits card above for what changes when you flip this — and note that template-based EOI generation still uses the v1 formValues shape regardless of this setting (v2 template/use migration is on the roadmap).',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'v1', label: 'v1 — Documenso 1.x (legacy stable)' },
|
||||
{ value: 'v2', label: 'v2 — Documenso 2.x (envelope + embedded signing)' },
|
||||
{ value: 'v1', label: 'v1 — Documenso 1.13.x (default, stable)' },
|
||||
{ value: 'v2', label: 'v2 — Documenso 2.x (envelope, recommended for new ports)' },
|
||||
],
|
||||
defaultValue: 'v1',
|
||||
},
|
||||
@@ -53,6 +57,24 @@ const SIGNER_FIELDS: SettingFieldDef[] = [
|
||||
placeholder: 'dm@portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_developer_label',
|
||||
label: 'Developer signer — display label',
|
||||
description:
|
||||
'How the developer slot is referenced in email subjects + signer-progress UI copy. Defaults to "Developer" when blank.',
|
||||
type: 'string',
|
||||
placeholder: 'Developer',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_developer_user_id',
|
||||
label: 'Developer signer — linked CRM user (optional)',
|
||||
description:
|
||||
"Project Director RBAC binding. When set, the webhook handler fires an in-CRM notification for this user when it's their turn to sign — alongside the branded email. Leave blank if the developer slot doesn't map to a CRM user (e.g. external developer). Use the user's UUID from /admin/users.",
|
||||
type: 'string',
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_approver_name',
|
||||
label: 'Approver — name',
|
||||
@@ -70,6 +92,24 @@ const SIGNER_FIELDS: SettingFieldDef[] = [
|
||||
placeholder: 'sales@portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_approver_label',
|
||||
label: 'Approver — display label',
|
||||
description:
|
||||
'How the approver slot is referenced in email subjects + signer-progress UI copy. Defaults to "Approver" when blank.',
|
||||
type: 'string',
|
||||
placeholder: 'Approver',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_approver_user_id',
|
||||
label: 'Approver — linked CRM user (optional)',
|
||||
description:
|
||||
"Same as developer's linked user — when set, fires an in-CRM notification when it's the approver's turn. Use the user's UUID from /admin/users.",
|
||||
type: 'string',
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
const EOI_FIELDS: SettingFieldDef[] = [
|
||||
@@ -112,7 +152,7 @@ const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
|
||||
key: 'documenso_contract_template_id',
|
||||
label: 'Contract Documenso template ID (optional)',
|
||||
description:
|
||||
'Numeric template ID for sales contract generation. Leave blank to use the per-deal upload-and-place-fields flow instead (the typical path for contracts, since they are usually drafted custom per client).',
|
||||
'Numeric template ID for sales contract generation. Leave blank to use the per-interest upload-and-place-fields flow instead (the typical path for contracts, since they are usually drafted custom per client).',
|
||||
type: 'string',
|
||||
placeholder: '',
|
||||
defaultValue: '',
|
||||
@@ -121,7 +161,7 @@ const CONTRACT_RESERVATION_FIELDS: SettingFieldDef[] = [
|
||||
key: 'documenso_reservation_template_id',
|
||||
label: 'Reservation agreement Documenso template ID (optional)',
|
||||
description:
|
||||
'Numeric template ID for reservation agreements. Same logic — leave blank to upload per deal.',
|
||||
'Numeric template ID for reservation agreements. Same logic — leave blank to upload per interest.',
|
||||
type: 'string',
|
||||
placeholder: '',
|
||||
defaultValue: '',
|
||||
@@ -140,6 +180,30 @@ const EMBED_FIELDS: SettingFieldDef[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const V2_FEATURE_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'documenso_signing_order',
|
||||
label: 'Signing order',
|
||||
description:
|
||||
'PARALLEL = recipients can sign in any order (faster, current default). SEQUENTIAL = Documenso refuses to email recipient N+1 until recipient N has signed, enforcing client → developer → approver order on EOIs. Only applies when API version above is v2 — v1 instances ignore this and always behave as PARALLEL.',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'PARALLEL (default)' },
|
||||
{ value: 'SEQUENTIAL', label: 'SEQUENTIAL — enforce signing order (v2 only)' },
|
||||
],
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_redirect_url',
|
||||
label: 'Post-signing redirect URL',
|
||||
description:
|
||||
"URL Documenso redirects the signer to after they complete signing. Typically the marketing site's success page so signers land on a branded thank-you rather than Documenso's own page. Leave blank to use Documenso's default. v1 and v2 both honour this. Example: https://portnimara.com/sign/success",
|
||||
type: 'string',
|
||||
placeholder: 'https://portnimara.com/sign/success',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocumensoSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -148,6 +212,163 @@ export default function DocumensoSettingsPage() {
|
||||
description="API credentials, signer identities, and document generation behaviour. Use the test-connection button to verify a saved configuration before relying on it."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Info className="h-4 w-4" aria-hidden="true" />
|
||||
v1 vs v2 — what changes when you flip the API version
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
The CRM supports both Documenso 1.13.x (v1) and 2.x (v2). v1 is the default for
|
||||
backwards compatibility. v2 is recommended for new ports and unlocks the features below.
|
||||
Switching versions does <strong>not</strong> require any code changes — version-aware
|
||||
client methods pick the right endpoint per port. Switch, save, then run the
|
||||
test-connection button to confirm the chosen instance is actually on the matching
|
||||
Documenso version.
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
v2-only capabilities the CRM already uses when you pick v2
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Bulk field placement.</strong> One API call per envelope vs. v1's
|
||||
per-field POST loop. Faster contract generation, fewer transient retries on
|
||||
multi-field uploaded contracts.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Percent-based field coordinates.</strong> No page-dimension lookup needed
|
||||
— coordinates are portable across page sizes. v1 requires us to assume A4 for
|
||||
auto-placed fields.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Richer field metadata.</strong> TEXT labels & required flags, NUMBER
|
||||
min/max + format, CHECKBOX/DROPDOWN/RADIO option lists with defaults — all ignored
|
||||
by v1, surfaced by v2 in the signing UI.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>v2-flavoured webhook events.</strong> <code>RECIPIENT_VIEWED</code>,{' '}
|
||||
<code>RECIPIENT_SIGNED</code>, <code>DOCUMENT_RECIPIENT_COMPLETED</code>,{' '}
|
||||
<code>DOCUMENT_DECLINED</code>, <code>DOCUMENT_REMINDER_SENT</code> — all routed
|
||||
through the same dedup + audit pipeline as v1 events.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Envelope CRUD endpoints.</strong> <code>GET</code>, <code>DELETE</code>,
|
||||
<code>POST /envelope/create</code> (multipart),{' '}
|
||||
<code>POST /envelope/distribute</code>, <code>POST /envelope/redistribute</code>,{' '}
|
||||
<code>GET /envelope/{'{id}'}/download</code> — all routed through{' '}
|
||||
<code>/api/v2/envelope/...</code> when v2 is selected. The template-generate path
|
||||
is intentionally still v1 (relies on Documenso 2.x's backward-compat window —
|
||||
see the deferred-roadmap below).
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>One-call send.</strong> v2's <code>/envelope/distribute</code>{' '}
|
||||
returns per-recipient <code>signingUrl</code> in the same response — v1 requires a
|
||||
separate GET to fetch them. Faster send flow on the rep side.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Sequential signing enforcement.</strong> Pick SEQUENTIAL in the "v2
|
||||
signing behaviour" card below and Documenso 2.x refuses to email recipient
|
||||
N+1 until recipient N has signed. Eliminates the "approver signed before the
|
||||
developer did" race on EOIs.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>Post-signing redirect URL.</strong> Set in the "v2 signing
|
||||
behaviour" card; Documenso redirects the signer to that URL after they
|
||||
complete signing. Use to land clients on the marketing site's success page or
|
||||
back in the portal instead of Documenso's default thank-you page. (v1 honours
|
||||
this too — listed here because the admin setting was added with the v2 work.)
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/60 p-3 dark:border-amber-900/40 dark:bg-amber-950/30">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">
|
||||
v2 capabilities deferred (would need new code paths)
|
||||
</p>
|
||||
<ul className="space-y-1.5 text-muted-foreground">
|
||||
<li>
|
||||
<strong>
|
||||
Single-shot <code>/template/use</code>
|
||||
</strong>{' '}
|
||||
with v2 <code>prefillFields</code> by ID — current EOI flow uses{' '}
|
||||
<code>/api/v1/templates/{'{id}'}/generate-document</code> with{' '}
|
||||
<code>formValues</code> keyed by name. v2 instances accept both during their
|
||||
backward-compat window; full migration requires per-template field-ID capture in
|
||||
admin settings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
Update envelope metadata after creation (<code>/envelope/update</code>)
|
||||
</strong>{' '}
|
||||
— change title / subject / redirectUrl on a doc already in DRAFT/PENDING without
|
||||
re-generating.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Non-SIGNER recipient roles (CC / VIEWER)</strong> — APPROVER role is already
|
||||
used by the EOI template; CC + VIEWER not yet exposed in the recipient builder.
|
||||
Useful for sales managers who want a copy without a signature slot.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Sequential signing and post-signing redirect URL <strong>are now wired</strong> — see
|
||||
the new "v2 signing behaviour" card below to configure them.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Documenso API"
|
||||
description="Per-port API credentials. Leave blank to use the global env defaults."
|
||||
@@ -155,6 +376,12 @@ export default function DocumensoSettingsPage() {
|
||||
extra={<DocumensoTestButton />}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="v2 signing behaviour"
|
||||
description="Cross-cutting settings that apply to EOIs + uploaded contracts/reservations. Sequential signing is v2-only (v1 instances ignore it). Redirect URL is honoured by both v1 and v2 instances."
|
||||
fields={V2_FEATURE_FIELDS}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Signers (developer + approver)"
|
||||
description="Identity of the static signers in your Documenso templates. The client is always pulled from the interest's linked client record; these values fill the developer (signing order 2) and approver (signing order 3) slots."
|
||||
@@ -169,7 +396,7 @@ export default function DocumensoSettingsPage() {
|
||||
|
||||
<SettingsFormCard
|
||||
title="Contract & reservation templates (optional)"
|
||||
description="Most ports leave these blank because contracts/reservations are drafted per deal and uploaded for signing. Set a template ID only if you have a standardised contract/reservation Documenso template."
|
||||
description="Most ports leave these blank because contracts/reservations are drafted per interest and uploaded for signing. Set a template ID only if you have a standardised contract/reservation Documenso template."
|
||||
fields={CONTRACT_RESERVATION_FIELDS}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '@/components/admin/shared/settings-form-card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
||||
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
|
||||
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
@@ -30,22 +31,6 @@ const FIELDS: SettingFieldDef[] = [
|
||||
placeholder: 'sales@example.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_signature_html',
|
||||
label: 'Default signature (HTML)',
|
||||
description: 'Appended to the bottom of system-generated emails.',
|
||||
type: 'html',
|
||||
placeholder: '<p>-<br>The Port Nimara team</p>',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_footer_html',
|
||||
label: 'Email footer (HTML)',
|
||||
description: 'Legal/contact footer rendered at the very bottom of all emails.',
|
||||
type: 'html',
|
||||
placeholder: '<p style="font-size:11px;color:#888;">© Port Nimara · ul. ...</p>',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'smtp_host_override',
|
||||
label: 'SMTP host override',
|
||||
@@ -83,19 +68,20 @@ export default function EmailSettingsPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Email Settings"
|
||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank."
|
||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="From address & signature"
|
||||
description="Identity headers and shared HTML used by system-generated emails."
|
||||
fields={FIELDS.slice(0, 5)}
|
||||
title="From address"
|
||||
description="Identity headers used by system-generated emails."
|
||||
fields={FIELDS.slice(0, 3)}
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="SMTP transport overrides"
|
||||
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
|
||||
fields={FIELDS.slice(5)}
|
||||
fields={FIELDS.slice(3)}
|
||||
/>
|
||||
<SalesEmailConfigCard />
|
||||
<EmailRoutingCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function ErrorEventDetailPage() {
|
||||
<KV label="Name" value={event.errorName ?? '—'} mono />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Message</p>
|
||||
<p className="mt-0.5 font-mono whitespace-pre-wrap break-words">
|
||||
<p className="mt-0.5 font-mono whitespace-pre-wrap wrap-break-word">
|
||||
{event.errorMessage ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -195,7 +195,7 @@ export default function ErrorEventDetailPage() {
|
||||
<Copy className="mr-1.5 h-3 w-3" /> Copy
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="mt-1 max-h-96 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap break-words">
|
||||
<pre className="mt-1 max-h-96 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap wrap-break-word">
|
||||
{event.errorStack}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -211,7 +211,7 @@ export default function ErrorEventDetailPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap break-words">
|
||||
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap wrap-break-word">
|
||||
{event.requestBodyExcerpt}
|
||||
</pre>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,265 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Bell,
|
||||
Briefcase,
|
||||
Database,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Inbox,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
Palette,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Shield,
|
||||
Sliders,
|
||||
Tag,
|
||||
Upload,
|
||||
Users,
|
||||
UsersRound,
|
||||
Webhook,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
interface AdminSection {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Settings;
|
||||
}
|
||||
|
||||
interface AdminGroup {
|
||||
title: string;
|
||||
description: string;
|
||||
sections: AdminSection[];
|
||||
}
|
||||
|
||||
const GROUPS: AdminGroup[] = [
|
||||
{
|
||||
title: 'Access',
|
||||
description: 'Who can sign in and what they can do once they do.',
|
||||
sections: [
|
||||
{
|
||||
href: 'users',
|
||||
label: 'Users',
|
||||
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
href: 'invitations',
|
||||
label: 'Invitations',
|
||||
description: 'Send invitations, track pending invites, and resend or revoke them.',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
href: 'roles',
|
||||
label: 'Roles & Permissions',
|
||||
description: 'Default permission sets and per-port role overrides.',
|
||||
icon: Shield,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Configuration',
|
||||
description: 'Branding, integrations, and per-port settings.',
|
||||
sections: [
|
||||
{
|
||||
href: 'email',
|
||||
label: 'Email Settings',
|
||||
description: 'From address, signatures, and per-port SMTP overrides.',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
href: 'documenso',
|
||||
label: 'Documenso & EOI',
|
||||
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
href: 'reminders',
|
||||
label: 'Reminders',
|
||||
description: 'Default reminder behaviour and the daily-digest delivery window.',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
href: 'branding',
|
||||
label: 'Branding',
|
||||
description: 'App name, logo, primary color, and email header/footer HTML.',
|
||||
icon: Palette,
|
||||
},
|
||||
{
|
||||
href: 'settings',
|
||||
label: 'System Settings',
|
||||
description: 'Generic key/value configuration store for advanced flags.',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
href: 'webhooks',
|
||||
label: 'Webhooks',
|
||||
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
|
||||
icon: Webhook,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Content',
|
||||
description: 'Forms, templates, and labels that users see.',
|
||||
sections: [
|
||||
{
|
||||
href: 'forms',
|
||||
label: 'Forms',
|
||||
description: 'Form templates used by client-facing inquiry and intake flows.',
|
||||
icon: Sliders,
|
||||
},
|
||||
{
|
||||
href: 'templates',
|
||||
label: 'Document Templates',
|
||||
description: 'PDF + email templates with merge-field placeholders.',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
href: 'email-templates',
|
||||
label: 'Email Templates',
|
||||
description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
href: 'tags',
|
||||
label: 'Tags',
|
||||
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
|
||||
icon: Tag,
|
||||
},
|
||||
{
|
||||
href: 'custom-fields',
|
||||
label: 'Custom Fields',
|
||||
description: 'Tenant-defined fields for clients, yachts, and reservations.',
|
||||
icon: Key,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Data Quality',
|
||||
description: 'Cleanup, imports, and the audit trail.',
|
||||
sections: [
|
||||
{
|
||||
href: 'inquiries',
|
||||
label: 'Inquiry Inbox',
|
||||
description:
|
||||
'Submissions captured from the public marketing site (berth, residence, contact).',
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
href: 'sends',
|
||||
label: 'Send Log',
|
||||
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
href: 'duplicates',
|
||||
label: 'Duplicates',
|
||||
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
|
||||
icon: UsersRound,
|
||||
},
|
||||
{
|
||||
href: 'import',
|
||||
label: 'Bulk Import',
|
||||
description: 'CSV-driven imports for clients, yachts, and reservations.',
|
||||
icon: Upload,
|
||||
},
|
||||
{
|
||||
href: 'audit',
|
||||
label: 'Audit Log',
|
||||
description: 'Searchable log of every authenticated mutation in the system.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Operations',
|
||||
description: 'Health checks and disaster recovery.',
|
||||
sections: [
|
||||
{
|
||||
href: 'reports',
|
||||
label: 'Reports',
|
||||
description: 'Saved analytics views and ad-hoc query results.',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
href: 'monitoring',
|
||||
label: 'Queue Monitoring',
|
||||
description: 'BullMQ queue health, throughput, and retry diagnostics.',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
href: 'backup',
|
||||
label: 'Backup & Restore',
|
||||
description: 'Backup posture + retention policy (read-only).',
|
||||
icon: HardDrive,
|
||||
},
|
||||
{
|
||||
href: 'storage',
|
||||
label: 'Storage Backend',
|
||||
description:
|
||||
'Choose between S3-compatible object store or local filesystem; migrate between them.',
|
||||
icon: HardDrive,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tenancy',
|
||||
description: 'Multi-port and multi-install scaffolding.',
|
||||
sections: [
|
||||
{
|
||||
href: 'ports',
|
||||
label: 'Ports',
|
||||
description: 'Manage the marinas/ports this installation serves.',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
href: 'onboarding',
|
||||
label: 'Onboarding checklist',
|
||||
description: 'Setup checklist for fresh ports (read-only references).',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
description: 'Third-party providers wired into the app.',
|
||||
sections: [
|
||||
{
|
||||
href: 'ai',
|
||||
label: 'AI configuration',
|
||||
description:
|
||||
'Master switch + provider credentials shared by every AI surface (OCR, berth-PDF parser, future recommender embeddings).',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
href: 'ocr',
|
||||
label: 'Receipt OCR (per-feature)',
|
||||
description: 'Provider, model, and confidence thresholds for the receipt scanner.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
href: 'website-analytics',
|
||||
label: 'Website analytics (Umami)',
|
||||
description: 'Per-port Umami URL, API token, and Website ID.',
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
href: 'residential-stages',
|
||||
label: 'Residential pipeline stages',
|
||||
description:
|
||||
'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
|
||||
icon: ScrollText,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
import { AdminSectionsBrowser } from '@/components/admin/admin-sections-browser';
|
||||
|
||||
export default async function AdminLandingPage({
|
||||
params,
|
||||
@@ -271,43 +11,9 @@ export default async function AdminLandingPage({
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Administration"
|
||||
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
|
||||
description="Per-port configuration and system administration. Use the search to jump to a setting, or browse the grouped index below."
|
||||
/>
|
||||
{GROUPS.map((group) => (
|
||||
<section key={group.title} className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground/80">{group.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{group.sections.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Link
|
||||
key={s.href}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/admin/${s.href}` as any}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">{s.label}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>{s.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<AdminSectionsBrowser portSlug={portSlug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QualificationCriteriaAdmin } from '@/components/admin/qualification-criteria-admin';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function QualificationCriteriaPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Qualification criteria"
|
||||
eyebrow="ADMIN"
|
||||
description="Configure the checklist reps complete before an interest moves out of the Enquiry stage. Reorder, enable/disable, or add port-specific criteria. The 'fully qualified' hint on the interest detail surfaces when every enabled criterion is confirmed."
|
||||
/>
|
||||
<QualificationCriteriaAdmin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { VocabulariesManager } from '@/components/admin/vocabularies/vocabularies-manager';
|
||||
|
||||
export default function VocabulariesPage() {
|
||||
return <VocabulariesManager />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { WebhookForm } from '@/components/admin/webhooks/webhook-form';
|
||||
import { WebhookDeliveryLog } from '@/components/admin/webhooks/webhook-delivery-log';
|
||||
import { WebhookSecretDisplay } from '@/components/admin/webhooks/webhook-secret-display';
|
||||
@@ -30,9 +32,10 @@ interface Webhook {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const WEBHOOKS_QUERY_KEY = ['admin', 'webhooks'] as const;
|
||||
|
||||
export default function WebhooksPage() {
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const queryClient = useQueryClient();
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Webhook | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
|
||||
@@ -44,20 +47,12 @@ export default function WebhooksPage() {
|
||||
masked: string;
|
||||
} | null>(null);
|
||||
|
||||
const loadWebhooks = useCallback(async () => {
|
||||
try {
|
||||
const result = await apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks');
|
||||
setWebhooks(result.data);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load webhooks');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const { data: webhooks = [], isLoading: loading } = useQuery<Webhook[]>({
|
||||
queryKey: WEBHOOKS_QUERY_KEY,
|
||||
queryFn: () => apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks').then((r) => r.data),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void loadWebhooks();
|
||||
}, [loadWebhooks]);
|
||||
const loadWebhooks = () => queryClient.invalidateQueries({ queryKey: WEBHOOKS_QUERY_KEY });
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
@@ -67,7 +62,7 @@ export default function WebhooksPage() {
|
||||
toast.success('Webhook deleted');
|
||||
void loadWebhooks();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete webhook');
|
||||
toastError(err, 'Failed to delete webhook');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +76,7 @@ export default function WebhooksPage() {
|
||||
setNewSecret({ webhookId, secret: result.data.secret, masked: result.data.secretMasked });
|
||||
void loadWebhooks();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to regenerate secret');
|
||||
toastError(err, 'Failed to regenerate secret');
|
||||
} finally {
|
||||
setRegenerating(null);
|
||||
}
|
||||
@@ -96,7 +91,7 @@ export default function WebhooksPage() {
|
||||
toast.success(webhook.isActive ? 'Webhook disabled' : 'Webhook enabled');
|
||||
void loadWebhooks();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to toggle webhook');
|
||||
toastError(err, 'Failed to toggle webhook');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AlertsPage() {
|
||||
return <AlertsPageShell />;
|
||||
// Legacy /alerts route — merged into /inbox in 2026-05-11. The hash
|
||||
// scrolls + expands the Alerts section on the merged page, so old
|
||||
// bookmarks land in the right spot.
|
||||
export default async function AlertsRedirect({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/inbox#alerts`);
|
||||
}
|
||||
|
||||
29
src/app/(dashboard)/[portSlug]/berths/[berthId]/loading.tsx
Normal file
29
src/app/(dashboard)/[portSlug]/berths/[berthId]/loading.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3 rounded-xl border border-border bg-card px-5 py-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-12 w-13 rounded-2xl" />
|
||||
<Skeleton className="h-7 w-32" />
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-9 w-32 rounded-md" />
|
||||
<Skeleton className="h-9 w-20 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 border-b border-border pb-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-24 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3 rounded-xl border border-border bg-card px-5 py-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-64" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-9 w-20 rounded-md" />
|
||||
<Skeleton className="h-9 w-24 rounded-md" />
|
||||
<Skeleton className="h-9 w-28 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 border-b border-border pb-1">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-20 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,45 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles, type UserPreferences } from '@/lib/db/schema/users';
|
||||
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardShell />;
|
||||
/**
|
||||
* Prefetch the user's first name + dashboard widget visibility server-side so
|
||||
* the dashboard renders its first paint with the rep's name and saved layout
|
||||
* already populated. Without this prefetch the page flickered three times on
|
||||
* cold cache: SSR fallback → /me arrives (firstName lands) → /preferences
|
||||
* arrives (widget layout reflows). All three caches are seeded synchronously
|
||||
* from the same DB row so the post-mount useQuery resolves instantly.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
// Resolve the signed-in user from the session cookie. The (dashboard)
|
||||
// layout already gates unauthenticated access; this is the second-pass
|
||||
// lookup that gives us the profile row.
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user) {
|
||||
// The outer layout will redirect; bail with the un-prefetched shell so
|
||||
// we don't crash if this server component is invoked in a non-auth
|
||||
// context (e.g. a future preview / RSC sub-route).
|
||||
return <DashboardShell />;
|
||||
}
|
||||
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, session.user.id),
|
||||
columns: {
|
||||
firstName: true,
|
||||
preferences: true,
|
||||
},
|
||||
});
|
||||
|
||||
const prefs = (profile?.preferences ?? {}) as UserPreferences;
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
initialFirstName={profile?.firstName ?? null}
|
||||
initialWidgetVisibility={prefs.dashboardWidgets ?? null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Grid, List, Upload } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { FileGrid } from '@/components/files/file-grid';
|
||||
import { FolderTree } from '@/components/files/folder-tree';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useFileBrowserStore } from '@/stores/file-browser-store';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { FileRow } from '@/components/files/file-grid';
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { viewMode, setViewMode, currentFolder, setCurrentFolder } = useFileBrowserStore();
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
|
||||
const [, setRenameFile] = useState<FileRow | null>(null);
|
||||
|
||||
const { data, isLoading } = usePaginatedQuery<FileRow & { storagePath: string }>({
|
||||
queryKey: ['files'],
|
||||
endpoint: '/api/v1/files',
|
||||
filterDefinitions: [],
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'file:uploaded': [['files']],
|
||||
'file:updated': [['files']],
|
||||
'file:deleted': [['files']],
|
||||
});
|
||||
|
||||
const filesInFolder = currentFolder
|
||||
? data.filter((f) => f.storagePath?.includes(currentFolder))
|
||||
: data;
|
||||
|
||||
const handleDownload = async (file: FileRow) => {
|
||||
try {
|
||||
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
||||
`/api/v1/files/${file.id}/download`,
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = res.data.url;
|
||||
a.download = res.data.filename;
|
||||
a.click();
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (file: FileRow) => {
|
||||
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Documents"
|
||||
description="Store and manage port documents and attachments"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid className="h-4 w-4" />}
|
||||
</Button>
|
||||
<PermissionGate resource="files" action="upload">
|
||||
<Button size="sm" onClick={() => setShowUpload((v) => !v)}>
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
Upload
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{showUpload && (
|
||||
<PermissionGate resource="files" action="upload">
|
||||
<FileUploadZone
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
setShowUpload(false);
|
||||
}}
|
||||
/>
|
||||
</PermissionGate>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 gap-4 overflow-hidden">
|
||||
{/* Folder tree sidebar */}
|
||||
<aside className="w-48 shrink-0 overflow-y-auto rounded-lg border bg-card p-2">
|
||||
<p className="mb-1 px-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Folders
|
||||
</p>
|
||||
<FolderTree
|
||||
files={data}
|
||||
currentFolder={currentFolder}
|
||||
onFolderSelect={setCurrentFolder}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto rounded-lg border bg-card p-4">
|
||||
<FileGrid
|
||||
files={filesInFolder}
|
||||
onDownload={handleDownload}
|
||||
onPreview={setPreviewFile}
|
||||
onRename={setRenameFile}
|
||||
onDelete={handleDelete}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FilePreviewDialog
|
||||
open={!!previewFile}
|
||||
onOpenChange={(open) => !open && setPreviewFile(null)}
|
||||
fileId={previewFile?.id}
|
||||
fileName={previewFile?.filename}
|
||||
mimeType={previewFile?.mimeType ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Plus, Download, FileText, FileSpreadsheet } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -21,8 +21,9 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
|
||||
import { ExpenseCard } from '@/components/expenses/expense-card';
|
||||
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
|
||||
import { buildExpenseFilterDefinitions } from '@/components/expenses/expense-filters';
|
||||
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
@@ -32,7 +33,20 @@ export default function ExpensesPage() {
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Per-port category override. Falls back to shipped defaults until the
|
||||
// vocab call resolves, so the filter bar always renders something.
|
||||
const { data: vocab } = useQuery<{ data: Record<string, readonly string[]> }>({
|
||||
queryKey: ['vocabularies'],
|
||||
queryFn: () => apiFetch('/api/v1/vocabularies'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const filterDefs = useMemo(
|
||||
() => buildExpenseFilterDefinitions(vocab?.data?.expense_categories),
|
||||
[vocab],
|
||||
);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
const [editExpense, setEditExpense] = useState<ExpenseRow | null>(null);
|
||||
const [archiveExpense, setArchiveExpense] = useState<ExpenseRow | null>(null);
|
||||
|
||||
@@ -51,7 +65,7 @@ export default function ExpensesPage() {
|
||||
} = usePaginatedQuery<ExpenseRow>({
|
||||
queryKey: ['expenses'],
|
||||
endpoint: '/api/v1/expenses',
|
||||
filterDefinitions: expenseFilterDefinitions,
|
||||
filterDefinitions: filterDefs,
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
@@ -130,7 +144,7 @@ export default function ExpensesPage() {
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
filters={expenseFilterDefinitions}
|
||||
filters={filterDefs}
|
||||
values={filters}
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { EXPENSE_CATEGORIES } from '@/lib/constants';
|
||||
import { EXPENSE_CATEGORIES, formatEnum } from '@/lib/constants';
|
||||
|
||||
interface ScanResult {
|
||||
establishment: string | null;
|
||||
@@ -345,7 +345,7 @@ export default function ScanReceiptPage() {
|
||||
<SelectContent>
|
||||
{EXPENSE_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
{formatEnum(cat)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/inbox/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/inbox/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InboxPageShell } from '@/components/inbox/inbox-page-shell';
|
||||
|
||||
export default function InboxPage() {
|
||||
return <InboxPageShell />;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3 rounded-xl border border-border bg-card px-5 py-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-72" />
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-9 w-24 rounded-md" />
|
||||
<Skeleton className="h-9 w-24 rounded-md" />
|
||||
<Skeleton className="h-9 w-28 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Pipeline-stepper row */}
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<div className="flex gap-2 border-b border-border pb-1">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-20 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,8 +22,11 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OwnerPicker } from '@/components/shared/owner-picker';
|
||||
import { CurrencySelect } from '@/components/shared/currency-select';
|
||||
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import type { z } from 'zod';
|
||||
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
|
||||
|
||||
const PAYMENT_TERMS = [
|
||||
@@ -74,7 +77,7 @@ export default function NewInvoicePage() {
|
||||
enabled: !!prefilledInterestId,
|
||||
});
|
||||
|
||||
const methods = useForm<CreateInvoiceInput>({
|
||||
const methods = useForm<z.input<typeof createInvoiceSchema>, unknown, CreateInvoiceInput>({
|
||||
resolver: zodResolver(createInvoiceSchema),
|
||||
defaultValues: {
|
||||
paymentTerms: 'net30',
|
||||
@@ -324,11 +327,10 @@ export default function NewInvoicePage() {
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Currency</Label>
|
||||
<Input
|
||||
{...register('currency')}
|
||||
placeholder="USD"
|
||||
maxLength={3}
|
||||
className="uppercase w-24"
|
||||
<CurrencySelect
|
||||
value={watchedValues.currency ?? 'USD'}
|
||||
onValueChange={(v) => setValue('currency', v, { shouldDirty: true })}
|
||||
className="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -352,7 +354,7 @@ export default function NewInvoicePage() {
|
||||
<CardTitle className="text-base">Line Items</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvoiceLineItems name="lineItems" />
|
||||
<InvoiceLineItems name="lineItems" currency={watchedValues.currency ?? 'USD'} />
|
||||
{errors.lineItems && !Array.isArray(errors.lineItems) && (
|
||||
<p className="text-xs text-destructive mt-2">
|
||||
{(errors.lineItems as { message?: string }).message}
|
||||
@@ -415,8 +417,10 @@ export default function NewInvoicePage() {
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<span>{li.description}</span>
|
||||
<span className="tabular-nums">
|
||||
{(Number(li.quantity) * Number(li.unitPrice)).toFixed(2)}{' '}
|
||||
{watchedValues.currency}
|
||||
{formatCurrency(
|
||||
Number(li.quantity) * Number(li.unitPrice),
|
||||
watchedValues.currency,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -427,21 +431,21 @@ export default function NewInvoicePage() {
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Subtotal</span>
|
||||
<span className="tabular-nums">
|
||||
{subtotal.toFixed(2)} {watchedValues.currency}
|
||||
{formatCurrency(subtotal, watchedValues.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{isNet10 && (
|
||||
<div className="flex justify-between text-green-600">
|
||||
<span>Net 10 Discount (~2%)</span>
|
||||
<span className="tabular-nums">
|
||||
-{discountAmount.toFixed(2)} {watchedValues.currency}
|
||||
-{formatCurrency(discountAmount, watchedValues.currency)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-semibold border-t pt-2 mt-1">
|
||||
<span>Total</span>
|
||||
<span className="tabular-nums">
|
||||
{total.toFixed(2)} {watchedValues.currency}
|
||||
{formatCurrency(total, watchedValues.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -165,7 +165,7 @@ export default function InvoicesPage() {
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-xs z-50 flex items-center justify-center">
|
||||
<div className="bg-background border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
|
||||
<h3 className="font-semibold">Delete Invoice?</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
17
src/app/(dashboard)/[portSlug]/loading.tsx
Normal file
17
src/app/(dashboard)/[portSlug]/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PageSkeleton } from '@/components/shared/loading-skeleton';
|
||||
|
||||
/**
|
||||
* Default route-level loading UI for every page under `(dashboard)/[portSlug]/...`.
|
||||
*
|
||||
* Renders while the server component resolves the session, port config,
|
||||
* and the client component bootstraps its initial query. Replaces the
|
||||
* empty-header flash on cold direct-URL visits and tab navigations.
|
||||
*
|
||||
* Individual routes can still ship their own `loading.tsx` for a more
|
||||
* tailored skeleton (see `clients/[clientId]/loading.tsx` which mirrors
|
||||
* the detail page's tab strip). When that file exists Next.js uses it
|
||||
* in place of this default.
|
||||
*/
|
||||
export default function Loading() {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
|
||||
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function NotificationPreferencesPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Notification Preferences</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which notifications you receive and how.
|
||||
</p>
|
||||
</div>
|
||||
<NotificationPreferencesForm />
|
||||
<ReminderDigestForm />
|
||||
</div>
|
||||
);
|
||||
interface PageProps {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy route. Notification preferences now live on the user-settings
|
||||
* page alongside every other personal preference. Kept as a redirect so
|
||||
* older bookmarks / email links still land somewhere useful.
|
||||
*/
|
||||
export default async function NotificationPreferencesRedirect({ params }: PageProps) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/settings#notifications`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { ReminderList } from '@/components/reminders/reminder-list';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function RemindersPage() {
|
||||
return <ReminderList />;
|
||||
// Legacy /reminders route — merged into /inbox in 2026-05-11. The hash
|
||||
// scrolls + expands the Reminders section on the merged page.
|
||||
export default async function RemindersRedirect({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/inbox#reminders`);
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { UserProfile } from '@/components/settings/user-profile';
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <UserProfile />;
|
||||
}
|
||||
29
src/app/(dashboard)/[portSlug]/yachts/[yachtId]/loading.tsx
Normal file
29
src/app/(dashboard)/[portSlug]/yachts/[yachtId]/loading.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3 rounded-xl border border-border bg-card px-5 py-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-9 w-20 rounded-md" />
|
||||
<Skeleton className="h-9 w-24 rounded-md" />
|
||||
<Skeleton className="h-9 w-28 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 border-b border-border pb-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-20 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { Topbar } from '@/components/layout/topbar';
|
||||
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
||||
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
||||
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
@@ -40,6 +41,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
<PermissionsProvider>
|
||||
<SocketProvider>
|
||||
<RealtimeToasts />
|
||||
<WebVitalsReporter />
|
||||
{/* Desktop shell - hidden by CSS on mobile */}
|
||||
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar
|
||||
@@ -59,7 +61,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
email: session.user.email,
|
||||
}}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto bg-background pt-3 px-6 pb-6">
|
||||
<main className="flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
||||
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { getPortalDashboard } from '@/lib/services/portal.service';
|
||||
import { isPortalDisabledGlobally } from '@/lib/services/portal-auth.service';
|
||||
import { PortalHeader } from '@/components/portal/portal-header';
|
||||
import { PortalNav } from '@/components/portal/portal-nav';
|
||||
|
||||
@@ -13,6 +14,25 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
// Route-level kill switch. When every port has client_portal_enabled=false,
|
||||
// surface a clean "Portal not available" notice instead of letting the
|
||||
// login form render (it would just reject every submit with a confusing
|
||||
// ConflictError). Single-port deployments effectively get a global toggle
|
||||
// out of the admin System Settings UI.
|
||||
if (await isPortalDisabledGlobally()) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="max-w-md text-center space-y-3">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Client portal unavailable</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
The client portal isn't currently enabled for this site. If you were expecting to
|
||||
sign in here, please contact your account manager.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// This layout wraps all portal routes including login/verify
|
||||
// We can't easily check pathname in a server layout, so we attempt
|
||||
// to get the session and pass it down - login/verify pages handle their own
|
||||
|
||||
@@ -36,28 +36,21 @@ export default async function PortalDocumentsPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Documents</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Your contracts, EOIs, and signed agreements
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Your contracts, EOIs, and signed agreements</p>
|
||||
</div>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<FileText className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 font-medium">No documents on file</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Documents shared with you will appear here.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Documents shared with you will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="bg-white rounded-lg border p-5"
|
||||
>
|
||||
<div key={doc.id} className="bg-white rounded-lg border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<FileText className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<FileText className="h-5 w-5 text-gray-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -66,7 +59,7 @@ export default async function PortalDocumentsPage() {
|
||||
{DOC_TYPE_LABELS[doc.documentType] ?? doc.documentType}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge variant={STATUS_COLORS[doc.status] ?? 'default'}>
|
||||
{doc.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
@@ -89,7 +82,11 @@ export default async function PortalDocumentsPage() {
|
||||
: 'text-gray-500'
|
||||
}
|
||||
>
|
||||
{signer.status === 'signed' ? '✓' : signer.status === 'declined' ? '✗' : '○'}
|
||||
{signer.status === 'signed'
|
||||
? '✓'
|
||||
: signer.status === 'declined'
|
||||
? '✗'
|
||||
: '○'}
|
||||
</span>
|
||||
<span className="text-gray-700">{signer.signerName}</span>
|
||||
<span className="text-gray-400 capitalize">
|
||||
|
||||
@@ -9,16 +9,40 @@ import { stageLabel, safeStage, type PipelineStage } from '@/lib/constants';
|
||||
|
||||
export const metadata: Metadata = { title: 'Interests' };
|
||||
|
||||
// Portal-friendly labels for signing-process status fields. The audit
|
||||
// caught raw enum leak ("waiting_for_signatures" with underscores) at
|
||||
// the client-facing surface. Map every known value to plain English;
|
||||
// fall back to a Title-Case rendering for any new states.
|
||||
const PORTAL_SIGNING_LABELS: Record<string, string> = {
|
||||
not_started: 'Not started',
|
||||
draft: 'Drafted',
|
||||
awaiting_them: 'Awaiting their signature',
|
||||
awaiting_me: 'Awaiting your signature',
|
||||
waiting_for_signatures: 'Waiting for signatures',
|
||||
partially_signed: 'Partially signed',
|
||||
sent: 'Sent for signing',
|
||||
signed: 'Signed',
|
||||
completed: 'Signed',
|
||||
expired: 'Expired',
|
||||
cancelled: 'Cancelled',
|
||||
rejected: 'Rejected',
|
||||
};
|
||||
function portalSigningLabel(status: string): string {
|
||||
if (status in PORTAL_SIGNING_LABELS) return PORTAL_SIGNING_LABELS[status]!;
|
||||
return status
|
||||
.split('_')
|
||||
.map((p) => (p ? p[0]!.toUpperCase() + p.slice(1) : p))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const STAGE_VARIANT: Record<PipelineStage, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
open: 'secondary',
|
||||
details_sent: 'secondary',
|
||||
in_communication: 'default',
|
||||
eoi_sent: 'default',
|
||||
eoi_signed: 'default',
|
||||
deposit_10pct: 'default',
|
||||
contract_sent: 'default',
|
||||
contract_signed: 'default',
|
||||
completed: 'outline',
|
||||
enquiry: 'secondary',
|
||||
qualified: 'secondary',
|
||||
nurturing: 'secondary',
|
||||
eoi: 'default',
|
||||
reservation: 'default',
|
||||
deposit_paid: 'default',
|
||||
contract: 'outline',
|
||||
};
|
||||
|
||||
export default async function PortalInterestsPage() {
|
||||
@@ -60,11 +84,10 @@ export default async function PortalInterestsPage() {
|
||||
<span className="text-sm text-gray-400">- {interest.berthArea}</span>
|
||||
)}
|
||||
</div>
|
||||
{interest.leadCategory && (
|
||||
<p className="text-sm text-gray-500 capitalize">
|
||||
{interest.leadCategory.replace(/_/g, ' ')}
|
||||
</p>
|
||||
)}
|
||||
{/* leadCategory ("hot_lead" / "qualified_lead" / etc.)
|
||||
is a staff classification — never render to clients.
|
||||
Privacy + optics: we shouldn't be telling the
|
||||
prospect they're a "hot lead". */}
|
||||
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-400">
|
||||
{interest.dateFirstContact && (
|
||||
<span>
|
||||
@@ -77,10 +100,10 @@ export default async function PortalInterestsPage() {
|
||||
</span>
|
||||
)}
|
||||
{interest.eoiStatus && (
|
||||
<span>EOI: {interest.eoiStatus.replace(/_/g, ' ')}</span>
|
||||
<span>EOI: {portalSigningLabel(interest.eoiStatus)}</span>
|
||||
)}
|
||||
{interest.contractStatus && (
|
||||
<span>Contract: {interest.contractStatus.replace(/_/g, ' ')}</span>
|
||||
<span>Contract: {portalSigningLabel(interest.contractStatus)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Metadata } from 'next';
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { getClientInvoices } from '@/lib/services/portal.service';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
|
||||
export const metadata: Metadata = { title: 'Invoices' };
|
||||
|
||||
@@ -16,16 +17,6 @@ const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'o
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
function formatCurrency(amount: string, currency: string): string {
|
||||
const num = parseFloat(amount);
|
||||
if (isNaN(num)) return `${currency} ${amount}`;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export default async function PortalInvoicesPage() {
|
||||
const session = await getPortalSession();
|
||||
if (!session) redirect('/portal/login');
|
||||
@@ -36,9 +27,7 @@ export default async function PortalInvoicesPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Invoices</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Your billing statements and payment history
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Your billing statements and payment history</p>
|
||||
</div>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
@@ -52,10 +41,7 @@ export default async function PortalInvoicesPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{invoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="bg-white rounded-lg border p-5"
|
||||
>
|
||||
<div key={invoice.id} className="bg-white rounded-lg border p-5">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -85,7 +71,7 @@ export default async function PortalInvoicesPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</p>
|
||||
|
||||
@@ -10,10 +10,27 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
/**
|
||||
* Validate the `?next=` post-login redirect target. auth-flow-auditor M10:
|
||||
* an unvalidated `next` lets `/portal/login?next=https://evil.example`
|
||||
* navigate cross-site after sign-in. Only allow same-origin paths
|
||||
* scoped to the portal surface — anything else falls back to the
|
||||
* dashboard.
|
||||
*/
|
||||
function safeNextPath(raw: string | null): string {
|
||||
const fallback = '/portal/dashboard';
|
||||
if (!raw) return fallback;
|
||||
// Reject absolute URLs (http://, https://, //evil.example) and
|
||||
// protocol-relative URLs. Only `/portal/...` paths are kept.
|
||||
if (!raw.startsWith('/portal/')) return fallback;
|
||||
if (raw.startsWith('//')) return fallback;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default function PortalLoginPage() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const next = search.get('next') ?? '/portal/dashboard';
|
||||
const next = safeNextPath(search.get('next'));
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
@@ -40,7 +40,7 @@ export default async function PortalMyYachtsPage() {
|
||||
{yachts.map((y) => (
|
||||
<div key={y.id} className="bg-white rounded-lg border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<Sailboat className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<Sailboat className="h-5 w-5 text-gray-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
307
src/app/(portal)/public/supplemental-info/[token]/page.tsx
Normal file
307
src/app/(portal)/public/supplemental-info/[token]/page.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
interface PrefillData {
|
||||
token: { expiresAt: string; consumed: boolean };
|
||||
client: {
|
||||
fullName: string;
|
||||
streetAddress: string | null;
|
||||
city: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
primaryEmail: string | null;
|
||||
primaryPhone: string | null;
|
||||
primaryPhoneCountry: string | null;
|
||||
};
|
||||
yacht: {
|
||||
name: string | null;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
export default function SupplementalInfoPage({ params }: PageProps) {
|
||||
const { token } = use(params);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<PrefillData | null>(null);
|
||||
|
||||
// Form fields
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [country, setCountry] = useState<CountryCode | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState<PhoneInputValue | null>(null);
|
||||
const [yachtName, setYachtName] = useState('');
|
||||
const [yachtLength, setYachtLength] = useState('');
|
||||
const [yachtWidth, setYachtWidth] = useState('');
|
||||
const [yachtDraft, setYachtDraft] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/public/supplemental-info/${token}`);
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
res.status === 404
|
||||
? 'This link is no longer valid. It may have expired or already been used.'
|
||||
: 'Could not load this form. Please try again later.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = (await res.json()) as { data: PrefillData };
|
||||
if (cancelled) return;
|
||||
setData(payload.data);
|
||||
setFullName(payload.data.client.fullName ?? '');
|
||||
setAddress(payload.data.client.streetAddress ?? '');
|
||||
setCountry((payload.data.client.country as CountryCode | null) ?? null);
|
||||
setEmail(payload.data.client.primaryEmail ?? '');
|
||||
if (payload.data.client.primaryPhone) {
|
||||
setPhone({
|
||||
e164: payload.data.client.primaryPhone,
|
||||
country: (payload.data.client.primaryPhoneCountry as CountryCode | null) ?? 'US',
|
||||
});
|
||||
}
|
||||
if (payload.data.yacht) {
|
||||
setYachtName(payload.data.yacht.name ?? '');
|
||||
setYachtLength(payload.data.yacht.lengthFt ?? '');
|
||||
setYachtWidth(payload.data.yacht.widthFt ?? '');
|
||||
setYachtDraft(payload.data.yacht.draftFt ?? '');
|
||||
}
|
||||
} catch {
|
||||
setError('Could not load this form. Please check your connection and try again.');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!fullName.trim()) {
|
||||
toast.error('Please enter your full name.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/public/supplemental-info/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fullName: fullName.trim(),
|
||||
address: address.trim() || null,
|
||||
country: country ?? null,
|
||||
email: email.trim() || null,
|
||||
phoneE164: phone?.e164 ?? null,
|
||||
phoneCountry: phone?.country ?? null,
|
||||
yachtName: yachtName.trim() || null,
|
||||
yachtLengthFt: parseFloat(yachtLength) || null,
|
||||
yachtWidthFt: parseFloat(yachtWidth) || null,
|
||||
yachtDraftFt: parseFloat(yachtDraft) || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
||||
toast.error(payload.error?.message ?? 'Failed to submit. Please try again.');
|
||||
return;
|
||||
}
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
toast.error('Failed to submit. Please check your connection.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center space-y-2 py-6">
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.token.consumed) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center space-y-3 py-6">
|
||||
<CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" />
|
||||
<h1 className="text-lg font-semibold">Thanks — we already have your details</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This form was already submitted. Your sales contact will be in touch shortly.
|
||||
</p>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<div className="text-center space-y-3 py-6">
|
||||
<CheckCircle2 className="h-10 w-10 text-emerald-600 mx-auto" />
|
||||
<h1 className="text-lg font-semibold">Thanks — got it</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your details have been sent to the team. Watch your inbox for your EOI document shortly.
|
||||
</p>
|
||||
</div>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandedAuthShell>
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-xl font-semibold">A few details before we draft your EOI</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We've pre-filled what we have on file. Please review, correct anything that's
|
||||
wrong, and add what's missing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-4">
|
||||
<legend className="text-sm font-semibold">Your details</legend>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="fullName">Full name</Label>
|
||||
<Input
|
||||
id="fullName"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="phone">Phone</Label>
|
||||
<PhoneInput id="phone" value={phone} onChange={setPhone} placeholder="Phone number" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Use a different number than the one you signed up with if you'd prefer to be
|
||||
reached there instead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="address">Address</Label>
|
||||
<Textarea
|
||||
id="address"
|
||||
rows={2}
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="Street, city, postal code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Country</Label>
|
||||
<CountryCombobox value={country} onChange={(c) => setCountry(c ?? null)} clearable />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-4">
|
||||
<legend className="text-sm font-semibold">Your yacht (optional)</legend>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="yachtName">Yacht name</Label>
|
||||
<Input
|
||||
id="yachtName"
|
||||
value={yachtName}
|
||||
onChange={(e) => setYachtName(e.target.value)}
|
||||
placeholder="Name on the hull"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="length">Length (ft)</Label>
|
||||
<Input
|
||||
id="length"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={yachtLength}
|
||||
onChange={(e) => setYachtLength(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="width">Width (ft)</Label>
|
||||
<Input
|
||||
id="width"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={yachtWidth}
|
||||
onChange={(e) => setYachtWidth(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="draft">Draft (ft)</Label>
|
||||
<Input
|
||||
id="draft"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={yachtDraft}
|
||||
onChange={(e) => setYachtDraft(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={submitting}>
|
||||
{submitting ? 'Submitting…' : 'Submit'}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
This link is private to you and expires after one use.
|
||||
</p>
|
||||
</form>
|
||||
</BrandedAuthShell>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export default async function ScannerLayout({
|
||||
return (
|
||||
<QueryProvider>
|
||||
<PortProvider ports={[port]} defaultPortId={port.id}>
|
||||
<div className="min-h-[100dvh] bg-background">{children}</div>
|
||||
<div className="min-h-dvh bg-background">{children}</div>
|
||||
</PortProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
|
||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||
import { enforcePublicRateLimit, parseBody } from '@/lib/api/route-helpers';
|
||||
|
||||
const bodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
@@ -16,24 +16,8 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
if (limited) return limited;
|
||||
|
||||
try {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
// Use {error} via errorResponse so the envelope matches every other
|
||||
// route (auditor-F §32 — was emitting {message} as a third variant).
|
||||
throw new ValidationError('Invalid request body');
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
|
||||
const result = await consumeCrmInvite({
|
||||
token: parsed.data.token,
|
||||
password: parsed.data.password,
|
||||
});
|
||||
const { token, password } = await parseBody(req, bodySchema);
|
||||
const result = await consumeCrmInvite({ token, password });
|
||||
return NextResponse.json({ data: { email: result.email } });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
|
||||
110
src/app/api/auth/sign-in-by-identifier/route.ts
Normal file
110
src/app/api/auth/sign-in-by-identifier/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Server-side sign-in endpoint that accepts an email-or-username
|
||||
* `identifier`. The username → email resolution happens entirely server-
|
||||
* side, so the canonical email is never disclosed to the browser. This
|
||||
* closes the username-enumeration vector that the old
|
||||
* `/api/auth/resolve-identifier` endpoint left open (it echoed the real
|
||||
* email on a hit; a synthetic `@auth.invalid` email on a miss was
|
||||
* trivially distinguishable from a real one by domain).
|
||||
*
|
||||
* The endpoint POSTs to better-auth's `/api/auth/sign-in/email`
|
||||
* downstream so the response shape (cookies + JSON body) matches what
|
||||
* the existing client expects.
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { sql, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { user, userProfiles } from '@/lib/db/schema/users';
|
||||
import { checkRateLimit, rateLimitHeaders, rateLimiters } from '@/lib/rate-limit';
|
||||
|
||||
function clientIp(req: NextRequest): string {
|
||||
return (
|
||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
req.headers.get('x-real-ip') ??
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveToEmail(identifier: string): Promise<string | null> {
|
||||
const raw = identifier.trim();
|
||||
if (!raw) return null;
|
||||
if (raw.includes('@')) return raw;
|
||||
const normalized = raw.toLowerCase();
|
||||
const rows = await db
|
||||
.select({ email: user.email })
|
||||
.from(userProfiles)
|
||||
.innerJoin(user, eq(userProfiles.userId, user.id))
|
||||
.where(sql`LOWER(${userProfiles.username}) = ${normalized}`)
|
||||
.limit(1);
|
||||
return rows[0]?.email ?? null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Rate-limit on IP — same 5/15min bucket the sign-in endpoint uses.
|
||||
const ip = clientIp(req);
|
||||
const rl = await checkRateLimit(ip, rateLimiters.auth);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: { message: 'Too many attempts. Try again later.' } },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
identifier?: string;
|
||||
password?: string;
|
||||
rememberMe?: boolean;
|
||||
callbackURL?: string;
|
||||
};
|
||||
const identifier = (body.identifier ?? '').trim();
|
||||
const password = body.password ?? '';
|
||||
if (!identifier || !password) {
|
||||
// Match better-auth's invalid-credentials shape so the client can
|
||||
// surface a uniform error without distinguishing the failure mode.
|
||||
return NextResponse.json(
|
||||
{ error: { message: 'Invalid credentials', code: 'INVALID_EMAIL_OR_PASSWORD' } },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const email = await resolveToEmail(identifier);
|
||||
// On a username miss we still call better-auth with a guaranteed-fail
|
||||
// email so the timing and response shape match the hit-with-wrong-
|
||||
// password path. The `.invalid` TLD is reserved by RFC 2606 so no real
|
||||
// user could ever match it.
|
||||
const effectiveEmail =
|
||||
email ?? `${identifier.replace(/[^a-z0-9._-]/gi, '').slice(0, 30) || 'unknown'}@auth.invalid`;
|
||||
|
||||
// Forward to better-auth's existing sign-in endpoint. We construct a
|
||||
// fresh Request because Next.js's NextRequest is read-only.
|
||||
const url = new URL('/api/auth/sign-in/email', req.url);
|
||||
const forwardBody = JSON.stringify({
|
||||
email: effectiveEmail,
|
||||
password,
|
||||
rememberMe: body.rememberMe,
|
||||
callbackURL: body.callbackURL,
|
||||
});
|
||||
const forwardReq = new Request(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Preserve client metadata for audit / rate limiting downstream.
|
||||
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? ip,
|
||||
'user-agent': req.headers.get('user-agent') ?? '',
|
||||
cookie: req.headers.get('cookie') ?? '',
|
||||
// CRITICAL: forward Origin + Referer so better-auth's CSRF check
|
||||
// passes. Without these the internal call lands as a cross-origin
|
||||
// request with no Origin → 403 MISSING_OR_NULL_ORIGIN, and the
|
||||
// user sees a generic "Invalid credentials" toast even though
|
||||
// the password is right. (Bug surfaced 2026-05-13 testing on
|
||||
// 192.168.1.17:3000 from an iPad.)
|
||||
...(req.headers.get('origin') ? { origin: req.headers.get('origin')! } : {}),
|
||||
...(req.headers.get('referer') ? { referer: req.headers.get('referer')! } : {}),
|
||||
},
|
||||
body: forwardBody,
|
||||
});
|
||||
|
||||
const { POST: signInHandler } = await import('@/app/api/auth/[...all]/route');
|
||||
return signInHandler(forwardReq as NextRequest);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { enforcePublicRateLimit, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { activateAccount } from '@/lib/services/portal-auth.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
@@ -16,19 +16,8 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
if (limited) return limited;
|
||||
|
||||
try {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new ValidationError('Invalid request body');
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
|
||||
await activateAccount(parsed.data.token, parsed.data.password);
|
||||
const { token, password } = await parseBody(req, bodySchema);
|
||||
await activateAccount(token, password);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
|
||||
@@ -4,7 +4,8 @@ import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { portalUsers } from '@/lib/db/schema/portal';
|
||||
import { errorResponse, UnauthorizedError, ValidationError } from '@/lib/errors';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, UnauthorizedError } from '@/lib/errors';
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { changePortalPassword } from '@/lib/services/portal-auth.service';
|
||||
|
||||
@@ -18,13 +19,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const session = await getPortalSession();
|
||||
if (!session) throw new UnauthorizedError('Portal session required');
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new ValidationError('Invalid request body');
|
||||
}
|
||||
const { currentPassword, newPassword } = bodySchema.parse(body);
|
||||
const { currentPassword, newPassword } = await parseBody(req, bodySchema);
|
||||
|
||||
const user = await db.query.portalUsers.findFirst({
|
||||
where: eq(portalUsers.email, session.email),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { enforcePublicRateLimit, parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { resetPassword } from '@/lib/services/portal-auth.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
@@ -16,19 +16,8 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
if (limited) return limited;
|
||||
|
||||
try {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new ValidationError('Invalid request body');
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
|
||||
await resetPassword(parsed.data.token, parsed.data.password);
|
||||
const { token, password } = await parseBody(req, bodySchema);
|
||||
await resetPassword(token, password);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
|
||||
@@ -69,7 +69,13 @@ export async function GET(
|
||||
const [berth] = await db
|
||||
.select()
|
||||
.from(berths)
|
||||
.where(and(eq(berths.portId, port.id), eq(berths.mooringNumber, mooringNumber)))
|
||||
.where(
|
||||
and(
|
||||
eq(berths.portId, port.id),
|
||||
eq(berths.mooringNumber, mooringNumber),
|
||||
isNull(berths.archivedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!berth) {
|
||||
|
||||
@@ -72,13 +72,12 @@ export async function GET(request: Request): Promise<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Active berths for the port (archived would be an explicit field
|
||||
// once we add one - today we don't have an archived_at on berths,
|
||||
// so we surface every row except those marked status='sold' on
|
||||
// request? No: §4.5 says "filters out berths archived in CRM".
|
||||
// The current schema has no archived flag for berths, so this is
|
||||
// a no-op today; future archive flag plugs in here.
|
||||
const berthRows = await db.select().from(berths).where(eq(berths.portId, port.id));
|
||||
// 1. Active berths for the port — retired moorings are hidden via
|
||||
// the archived_at soft-delete column (migration 0065).
|
||||
const berthRows = await db
|
||||
.select()
|
||||
.from(berths)
|
||||
.where(and(eq(berths.portId, port.id), isNull(berths.archivedAt)));
|
||||
|
||||
if (berthRows.length === 0) {
|
||||
return jsonResponse({ list: [], pageInfo: emptyPageInfo() });
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ports } from '@/lib/db/schema/ports';
|
||||
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||
import { publicInterestSchema } from '@/lib/validators/interests';
|
||||
@@ -44,8 +45,7 @@ export async function POST(req: NextRequest) {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
await gateRateLimit(ip);
|
||||
|
||||
const body = await req.json();
|
||||
const data = publicInterestSchema.parse(body);
|
||||
const data = await parseBody(req, publicInterestSchema);
|
||||
|
||||
// Resolve portId from query param or header (public endpoints need explicit port)
|
||||
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
|
||||
@@ -84,11 +84,18 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// ─── Transactional trio creation ────────────────────────────────────────
|
||||
const result = await withTransaction(async (tx) => {
|
||||
// 1. Find or create client by email (case-sensitive contact match, same
|
||||
// behavior as before the refactor).
|
||||
// 1. Find or create client by email. The inquiry-funnel audit
|
||||
// flagged that the previous exact match was case-sensitive —
|
||||
// capital-letter resubmissions spawned duplicate client+yacht+
|
||||
// interest rows. Match LOWER(value) instead so foo@x.com and
|
||||
// Foo@X.COM dedupe to the same client.
|
||||
let clientId: string;
|
||||
const normalizedEmail = data.email.trim().toLowerCase();
|
||||
const existingContact = await tx.query.clientContacts.findFirst({
|
||||
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
|
||||
where: and(
|
||||
eq(clientContacts.channel, 'email'),
|
||||
sql`LOWER(${clientContacts.value}) = ${normalizedEmail}`,
|
||||
),
|
||||
});
|
||||
if (existingContact) {
|
||||
const existingClient = await tx.query.clients.findFirst({
|
||||
@@ -320,7 +327,9 @@ async function createClientInTx(
|
||||
await tx.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'email',
|
||||
value: data.email,
|
||||
// Store lowercased so the case-insensitive dedup match above always
|
||||
// hits on subsequent submissions.
|
||||
value: data.email.trim().toLowerCase(),
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
import { env } from '@/lib/env';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||
@@ -48,8 +49,7 @@ export async function POST(req: NextRequest) {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
||||
await gateRateLimit(ip);
|
||||
|
||||
const body = await req.json();
|
||||
const data = publicResidentialInquirySchema.parse(body);
|
||||
const data = await parseBody(req, publicResidentialInquirySchema);
|
||||
|
||||
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
|
||||
if (!portId) {
|
||||
@@ -144,7 +144,7 @@ async function sendResidentialNotifications(args: {
|
||||
|
||||
const branding = await getBrandingShell(portId);
|
||||
// Client confirmation
|
||||
const confirmation = residentialClientConfirmation(
|
||||
const confirmation = await residentialClientConfirmation(
|
||||
{
|
||||
firstName: data.firstName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
@@ -186,7 +186,7 @@ async function sendResidentialNotifications(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
const alert = residentialSalesAlert(
|
||||
const alert = await residentialSalesAlert(
|
||||
{
|
||||
fullName: `${data.firstName} ${data.lastName}`.trim(),
|
||||
email: data.email,
|
||||
|
||||
68
src/app/api/public/supplemental-info/[token]/route.ts
Normal file
68
src/app/api/public/supplemental-info/[token]/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { loadByToken, applySubmission } from '@/lib/services/supplemental-forms.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
* Public — no auth. Loads the prefill data for the form. The token in
|
||||
* the URL is the only credential; rejects expired / unknown tokens with
|
||||
* 404 (deliberately conflated to avoid leaking which tokens exist).
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
ctx: { params: Promise<{ token: string }> },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { token } = await ctx.params;
|
||||
const data = await loadByToken(token);
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Link not found or expired' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
const submissionSchema = z.object({
|
||||
fullName: z.string().min(1).max(200),
|
||||
address: z.string().max(500).nullable().optional(),
|
||||
country: z.string().length(2).nullable().optional(),
|
||||
email: z.string().email().nullable().optional(),
|
||||
phoneE164: z
|
||||
.string()
|
||||
.regex(/^\+[1-9]\d{1,14}$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
phoneCountry: z.string().length(2).nullable().optional(),
|
||||
yachtName: z.string().max(200).nullable().optional(),
|
||||
yachtLengthFt: z.number().positive().nullable().optional(),
|
||||
yachtWidthFt: z.number().positive().nullable().optional(),
|
||||
yachtDraftFt: z.number().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ token: string }> },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { token } = await ctx.params;
|
||||
const body = submissionSchema.parse(await req.json());
|
||||
await applySubmission(token, {
|
||||
fullName: body.fullName,
|
||||
address: body.address ?? null,
|
||||
country: body.country ?? null,
|
||||
email: body.email ?? null,
|
||||
phoneE164: body.phoneE164 ?? null,
|
||||
phoneCountry: body.phoneCountry ?? null,
|
||||
yachtName: body.yachtName ?? null,
|
||||
yachtLengthFt: body.yachtLengthFt ?? null,
|
||||
yachtWidthFt: body.yachtWidthFt ?? null,
|
||||
yachtDraftFt: body.yachtDraftFt ?? null,
|
||||
});
|
||||
return NextResponse.json({ data: { success: true } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,13 @@ import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||
import { env } from '@/lib/env';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
AppError,
|
||||
errorResponse,
|
||||
RateLimitError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||
|
||||
@@ -38,7 +44,7 @@ import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||
const SubmissionSchema = z.object({
|
||||
submission_id: z.string().uuid(),
|
||||
kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']),
|
||||
payload: z.record(z.unknown()),
|
||||
payload: z.record(z.string(), z.unknown()),
|
||||
legacy_nocodb_id: z.string().optional(),
|
||||
/** Defaults to port-nimara since that's currently the only port with a
|
||||
* public marketing site. Future ports can override per-submission. */
|
||||
@@ -63,16 +69,15 @@ export async function POST(req: NextRequest) {
|
||||
// Refuse outright if the CRM hasn't been wired up - safer than letting
|
||||
// unauthenticated traffic in just because the env var was forgotten.
|
||||
if (!env.WEBSITE_INTAKE_SECRET) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Website intake is not configured on this server.' },
|
||||
{ status: 503 },
|
||||
return errorResponse(
|
||||
new AppError(503, 'Website intake is not configured on this server.', 'NOT_CONFIGURED'),
|
||||
);
|
||||
}
|
||||
|
||||
// Auth gate - shared secret in header, timing-safe compare.
|
||||
const secretHeader = req.headers.get('x-webhook-secret');
|
||||
if (!verifySecret(secretHeader)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return errorResponse(new UnauthorizedError());
|
||||
}
|
||||
|
||||
// Rate limit. All website-side traffic shares the website's egress IP,
|
||||
@@ -84,10 +89,7 @@ export async function POST(req: NextRequest) {
|
||||
const rl = await checkRateLimit(ip, rateLimiters.websiteIntake);
|
||||
if (!rl.allowed) {
|
||||
const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded' },
|
||||
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
|
||||
);
|
||||
return errorResponse(new RateLimitError(retryAfter));
|
||||
}
|
||||
|
||||
// Parse + validate body. Reject anything that doesn't conform — the
|
||||
@@ -97,9 +99,10 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
parsed = SubmissionSchema.parse(body);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid payload', details: err instanceof Error ? err.message : 'parse error' },
|
||||
{ status: 400 },
|
||||
return errorResponse(
|
||||
new ValidationError('Invalid payload', [
|
||||
{ field: 'body', message: err instanceof Error ? err.message : 'parse error' },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +122,7 @@ export async function POST(req: NextRequest) {
|
||||
{ portSlug: parsed.port_slug, submissionId: parsed.submission_id },
|
||||
'website-inquiry rejected: unknown port',
|
||||
);
|
||||
return NextResponse.json({ error: 'Unknown port' }, { status: 400 });
|
||||
return errorResponse(new ValidationError('Unknown port'));
|
||||
}
|
||||
|
||||
// Idempotent insert. Two parallel requests carrying the same submission_id
|
||||
|
||||
@@ -21,7 +21,13 @@ import { Readable } from 'node:stream';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { MAX_FILE_SIZE } from '@/lib/constants/file-validation';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
AppError,
|
||||
errorResponse,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { redis } from '@/lib/redis';
|
||||
import { FilesystemBackend, getStorageBackend } from '@/lib/storage';
|
||||
@@ -47,16 +53,13 @@ export async function GET(
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
if (!(backend instanceof FilesystemBackend)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Storage proxy is only available in filesystem mode' },
|
||||
{ status: 404 },
|
||||
);
|
||||
return errorResponse(new NotFoundError('storage proxy'));
|
||||
}
|
||||
|
||||
const result = verifyProxyToken(token, backend.getHmacSecret(), 'get');
|
||||
if (!result.ok) {
|
||||
logger.warn({ reason: result.reason }, 'Storage proxy token rejected');
|
||||
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 403 });
|
||||
return errorResponse(new ForbiddenError('Invalid or expired token'));
|
||||
}
|
||||
const { payload } = result;
|
||||
|
||||
@@ -72,7 +75,7 @@ export async function GET(
|
||||
const setOk = await redis.set(replayKey, '1', 'EX', remainingSeconds, 'NX');
|
||||
if (setOk !== 'OK') {
|
||||
logger.warn({ key: payload.k }, 'Storage proxy token replay rejected');
|
||||
return NextResponse.json({ error: 'Token already used' }, { status: 403 });
|
||||
return errorResponse(new ForbiddenError('Token already used'));
|
||||
}
|
||||
|
||||
let absolutePath: string;
|
||||
@@ -80,22 +83,22 @@ export async function GET(
|
||||
absolutePath = backend.resolveKeyForProxy(payload.k);
|
||||
} catch (err) {
|
||||
logger.warn({ err, key: payload.k }, 'Storage proxy key resolution failed');
|
||||
return NextResponse.json({ error: 'Invalid key' }, { status: 400 });
|
||||
return errorResponse(new ValidationError('Invalid key'));
|
||||
}
|
||||
|
||||
let size: number;
|
||||
try {
|
||||
const stat = await fs.stat(absolutePath);
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return errorResponse(new NotFoundError('file'));
|
||||
}
|
||||
size = stat.size;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return errorResponse(new NotFoundError('file'));
|
||||
}
|
||||
throw err;
|
||||
return errorResponse(err);
|
||||
}
|
||||
|
||||
// Convert the Node Readable into a Web ReadableStream for NextResponse.
|
||||
@@ -140,16 +143,13 @@ export async function PUT(
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
if (!(backend instanceof FilesystemBackend)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Storage proxy is only available in filesystem mode' },
|
||||
{ status: 404 },
|
||||
);
|
||||
return errorResponse(new NotFoundError('storage proxy'));
|
||||
}
|
||||
|
||||
const result = verifyProxyToken(token, backend.getHmacSecret(), 'put');
|
||||
if (!result.ok) {
|
||||
logger.warn({ reason: result.reason }, 'Storage proxy upload token rejected');
|
||||
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 403 });
|
||||
return errorResponse(new ForbiddenError('Invalid or expired token'));
|
||||
}
|
||||
const { payload } = result;
|
||||
|
||||
@@ -164,7 +164,7 @@ export async function PUT(
|
||||
const setOk = await redis.set(replayKey, '1', 'EX', remainingSeconds, 'NX');
|
||||
if (setOk !== 'OK') {
|
||||
logger.warn({ key: payload.k }, 'Storage proxy upload token replay rejected');
|
||||
return NextResponse.json({ error: 'Token already used' }, { status: 403 });
|
||||
return errorResponse(new ForbiddenError('Token already used'));
|
||||
}
|
||||
|
||||
// Pre-flight size check via Content-Length so a malicious caller can't
|
||||
@@ -172,14 +172,17 @@ export async function PUT(
|
||||
const contentLengthHeader = req.headers.get('content-length');
|
||||
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : NaN;
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: `File exceeds ${MAX_FILE_SIZE} byte cap (Content-Length: ${contentLength})` },
|
||||
{ status: 413 },
|
||||
return errorResponse(
|
||||
new AppError(
|
||||
413,
|
||||
`File exceeds ${MAX_FILE_SIZE} byte cap (Content-Length: ${contentLength})`,
|
||||
'PAYLOAD_TOO_LARGE',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.body) {
|
||||
return NextResponse.json({ error: 'Empty body' }, { status: 400 });
|
||||
return errorResponse(new ValidationError('Empty body'));
|
||||
}
|
||||
|
||||
// Read the body into a buffer with a hard cap. Filesystem deployments are
|
||||
@@ -200,9 +203,8 @@ export async function PUT(
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `File exceeds ${MAX_FILE_SIZE} byte cap` },
|
||||
{ status: 413 },
|
||||
return errorResponse(
|
||||
new AppError(413, `File exceeds ${MAX_FILE_SIZE} byte cap`, 'PAYLOAD_TOO_LARGE'),
|
||||
);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
@@ -210,7 +212,7 @@ export async function PUT(
|
||||
buffer = Buffer.concat(chunks);
|
||||
} catch (err) {
|
||||
logger.warn({ err, key: payload.k }, 'Storage proxy upload read failed');
|
||||
return NextResponse.json({ error: 'Upload read failed' }, { status: 400 });
|
||||
return errorResponse(new ValidationError('Upload read failed'));
|
||||
}
|
||||
|
||||
// Magic-byte gate: when the token was minted with `c=application/pdf`
|
||||
@@ -218,9 +220,8 @@ export async function PUT(
|
||||
// that isn't actually a PDF. Mirrors the post-upload check in
|
||||
// berth-pdf.service.ts so the two paths behave identically.
|
||||
if (payload.c === 'application/pdf' && !isPdfMagic(buffer)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Uploaded file failed PDF magic-byte check (does not start with %PDF-).' },
|
||||
{ status: 400 },
|
||||
return errorResponse(
|
||||
new ValidationError('Uploaded file failed PDF magic-byte check (does not start with %PDF-).'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
132
src/app/api/v1/admin/branding/logo/route.ts
Normal file
132
src/app/api/v1/admin/branding/logo/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import {
|
||||
clearPortLogo,
|
||||
getPortLogoFile,
|
||||
processLogoUpload,
|
||||
setPortLogo,
|
||||
type LogoCrop,
|
||||
} from '@/lib/services/logo.service';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
const MAX_RAW_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
function parseCrop(value: FormDataEntryValue | null): LogoCrop | undefined {
|
||||
if (typeof value !== 'string' || value.length === 0) return undefined;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new ValidationError('Invalid crop JSON');
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new ValidationError('Invalid crop payload');
|
||||
}
|
||||
const c = parsed as Record<string, unknown>;
|
||||
for (const key of ['x', 'y', 'width', 'height']) {
|
||||
if (typeof c[key] !== 'number' || !Number.isFinite(c[key])) {
|
||||
throw new ValidationError(`Crop ${key} must be a finite number`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: c.x as number,
|
||||
y: c.y as number,
|
||||
width: c.width as number,
|
||||
height: c.height as number,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/branding/logo
|
||||
* Returns metadata for the current port logo (or null).
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.portId) throw new ValidationError('No active port');
|
||||
const file = await getPortLogoFile(ctx.portId);
|
||||
if (!file) {
|
||||
return NextResponse.json({ data: null });
|
||||
}
|
||||
const baseUrl = env.APP_URL.replace(/\/+$/, '');
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
fileId: file.id,
|
||||
previewUrl: `${baseUrl}/api/v1/files/${file.id}/preview`,
|
||||
sizeBytes: file.sizeBytes,
|
||||
mimeType: file.mimeType,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/branding/logo
|
||||
*
|
||||
* Multipart: `file` (required) + `crop` (optional JSON string `{x, y, width, height}`).
|
||||
* Runs the sharp normalization pipeline; if accepted, atomically updates the
|
||||
* `port_logo_file_id` system setting and soft-archives the previous logo.
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
if (!ctx.portId) throw new ValidationError('No active port');
|
||||
const formData = await req.formData();
|
||||
const fileEntry = formData.get('file');
|
||||
if (!(fileEntry instanceof File)) throw new ValidationError('Missing `file` part');
|
||||
if (fileEntry.size === 0) throw new ValidationError('Empty file');
|
||||
if (fileEntry.size > MAX_RAW_BYTES) {
|
||||
throw new ValidationError(`File exceeds ${MAX_RAW_BYTES / 1024 / 1024} MB`);
|
||||
}
|
||||
const crop = parseCrop(formData.get('crop'));
|
||||
const buffer = Buffer.from(await fileEntry.arrayBuffer());
|
||||
const processed = await processLogoUpload(buffer, crop);
|
||||
const result = await setPortLogo(ctx.portId, processed, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
const baseUrl = env.APP_URL.replace(/\/+$/, '');
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
fileId: result.fileId,
|
||||
previewUrl: `${baseUrl}/api/v1/files/${result.fileId}/preview`,
|
||||
warnings: result.warnings,
|
||||
finalDimensions: processed.finalDimensions,
|
||||
finalBytes: processed.finalBytes,
|
||||
originalDimensions: processed.originalDimensions,
|
||||
originalFormat: processed.originalFormat,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/admin/branding/logo
|
||||
* Clear the port logo. Subsequent PDF renders fall back to the port-name text header.
|
||||
*/
|
||||
export const DELETE = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.portId) throw new ValidationError('No active port');
|
||||
await clearPortLogo(ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
42
src/app/api/v1/admin/branding/logo/sample-pdf/route.tsx
Normal file
42
src/app/api/v1/admin/branding/logo/sample-pdf/route.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
|
||||
import { renderPdf } from '@/lib/pdf/render';
|
||||
import { BrandingSamplePdf } from '@/lib/pdf/templates/branding-sample';
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/branding/logo/sample-pdf
|
||||
*
|
||||
* Renders a one-page PDF that exercises the brand-kit header, footer, and a
|
||||
* couple of tables/charts. Used by the admin Branding UI's "Test with sample
|
||||
* PDF" button so the admin can preview their logo in the actual report shell
|
||||
* before generating real reports.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.portId) throw new ValidationError('No active port');
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, ctx.portId) });
|
||||
if (!port) throw new ValidationError('Unknown port');
|
||||
const logo = await resolvePortLogo(port.id);
|
||||
const bytes = await renderPdf(
|
||||
<BrandingSamplePdf portName={port.name} logoBuffer={logo.buffer} />,
|
||||
);
|
||||
return new NextResponse(new Uint8Array(bytes), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/pdf',
|
||||
'content-disposition': 'inline; filename="branding-sample.pdf"',
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { and, eq, isNull, gte, sql } from 'drizzle-orm';
|
||||
import { and, eq, isNotNull, isNull, gte, sql } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
@@ -18,13 +18,26 @@ export const GET = withAuth(
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [pipelineRows, berthStatusRows, totals, recent] = await Promise.all([
|
||||
// Only active interests count toward per-stage breakdowns;
|
||||
// terminal (outcome-set) interests are tracked separately via
|
||||
// `closedTotal` below. Pre-2026-05-14 cleanup, terminal rows
|
||||
// were grouped under the sentinel pipeline_stage='completed'
|
||||
// bucket; the new convention leaves the stage where it was
|
||||
// when the outcome was set, so we must filter by `outcome IS
|
||||
// NULL` explicitly to keep the kanban breakdown honest.
|
||||
db
|
||||
.select({
|
||||
stage: interests.pipelineStage,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
isNull(interests.archivedAt),
|
||||
isNull(interests.outcome),
|
||||
),
|
||||
)
|
||||
.groupBy(interests.pipelineStage),
|
||||
|
||||
db
|
||||
@@ -49,6 +62,18 @@ export const GET = withAuth(
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, portId)),
|
||||
// closedTotal: all-time count of terminal (outcome-set)
|
||||
// non-archived interests. Drives the conversion-rate tile.
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interests)
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
isNull(interests.archivedAt),
|
||||
isNotNull(interests.outcome),
|
||||
),
|
||||
),
|
||||
]),
|
||||
|
||||
Promise.all([
|
||||
@@ -61,14 +86,18 @@ export const GET = withAuth(
|
||||
gte(websiteSubmissions.receivedAt, sevenDaysAgo),
|
||||
),
|
||||
),
|
||||
// "completed30d" = interests that hit a terminal outcome in
|
||||
// the last 30 days (any outcome — won, lost, or cancelled).
|
||||
// Use `outcome_at` not `updated_at` so unrelated edits to a
|
||||
// long-closed deal don't drag it back into the window.
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interests)
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
eq(interests.pipelineStage, 'completed'),
|
||||
gte(interests.updatedAt, thirtyDaysAgo),
|
||||
isNotNull(interests.outcome),
|
||||
gte(interests.outcomeAt, thirtyDaysAgo),
|
||||
),
|
||||
),
|
||||
]),
|
||||
@@ -90,10 +119,10 @@ export const GET = withAuth(
|
||||
const totalClients = totals[0][0]?.count ?? 0;
|
||||
const totalInterests = totals[1][0]?.count ?? 0;
|
||||
const totalBerths = totals[2][0]?.count ?? 0;
|
||||
const closedTotal = totals[3][0]?.count ?? 0;
|
||||
const newInquiries7d = recent[0][0]?.count ?? 0;
|
||||
const completed30d = recent[1][0]?.count ?? 0;
|
||||
|
||||
const closedTotal = pipeline['completed'] ?? 0;
|
||||
const openTotal = totalInterests - closedTotal;
|
||||
const conversionPct =
|
||||
totalInterests > 0 ? Math.round((closedTotal / totalInterests) * 100) : 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
@@ -87,5 +87,3 @@ export const PUT = withAuth(
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
void eq;
|
||||
|
||||
50
src/app/api/v1/admin/email/routing/route.ts
Normal file
50
src/app/api/v1/admin/email/routing/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { upsertSetting } from '@/lib/services/settings.service';
|
||||
import {
|
||||
EMAIL_CATEGORIES,
|
||||
EMAIL_ROUTING_KEY,
|
||||
getEmailRoutingMatrix,
|
||||
} from '@/lib/services/email-routing';
|
||||
|
||||
const senderSchema = z.enum(['noreply', 'sales']);
|
||||
const updateSchema = z.object({
|
||||
routing: z.record(z.enum(EMAIL_CATEGORIES), senderSchema),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
const matrix = await getEmailRoutingMatrix(ctx.portId);
|
||||
return NextResponse.json({
|
||||
data: { ...matrix, categories: EMAIL_CATEGORIES },
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const input = await parseBody(req, updateSchema);
|
||||
await upsertSetting(EMAIL_ROUTING_KEY, input.routing, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
const matrix = await getEmailRoutingMatrix(ctx.portId);
|
||||
return NextResponse.json({
|
||||
data: { ...matrix, categories: EMAIL_CATEGORIES },
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
40
src/app/api/v1/admin/qualification-criteria/[id]/route.ts
Normal file
40
src/app/api/v1/admin/qualification-criteria/[id]/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { updateQualificationCriterionSchema } from '@/lib/validators/qualification';
|
||||
import { deleteCriterion, updateCriterion } from '@/lib/services/qualification.service';
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateQualificationCriterionSchema);
|
||||
const row = await updateCriterion(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: row });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
|
||||
try {
|
||||
await deleteCriterion(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -3,20 +3,20 @@ import { NextResponse } from 'next/server';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { generateFromTemplate } from '@/lib/services/document-templates';
|
||||
import { generateSchema } from '@/lib/validators/document-templates';
|
||||
import { reorderQualificationCriteriaSchema } from '@/lib/validators/qualification';
|
||||
import { reorderCriteria } from '@/lib/services/qualification.service';
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'create', async (req, ctx, params) => {
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, generateSchema);
|
||||
const result = await generateFromTemplate(params.id!, ctx.portId, body, {
|
||||
const body = await parseBody(req, reorderQualificationCriteriaSchema);
|
||||
const rows = await reorderCriteria(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: result }, { status: 201 });
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
33
src/app/api/v1/admin/qualification-criteria/route.ts
Normal file
33
src/app/api/v1/admin/qualification-criteria/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createQualificationCriterionSchema } from '@/lib/validators/qualification';
|
||||
import { createCriterion, listCriteriaForPort } from '@/lib/services/qualification.service';
|
||||
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
const data = await listCriteriaForPort(ctx.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createQualificationCriterionSchema);
|
||||
const row = await createCriterion(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: row }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,74 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { generatePdf } from '@/lib/pdf/generate';
|
||||
import {
|
||||
validateTipTapDocument,
|
||||
tipTapToPdfmeTemplate,
|
||||
buildContentInputsFromDoc,
|
||||
substituteVariables,
|
||||
type TipTapNode,
|
||||
} from '@/lib/pdf/tiptap-to-pdfme';
|
||||
import { previewAdminTemplateSchema } from '@/lib/validators/document-templates';
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/templates/preview
|
||||
*
|
||||
* Generates a preview PDF from a TipTap JSON content block.
|
||||
* Returns { data: { pdfBase64: string } } - the client can render this
|
||||
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
|
||||
*
|
||||
* Body:
|
||||
* content: TipTap JSON document
|
||||
* sampleData?: Record<string, string> - variable substitutions
|
||||
*/
|
||||
export const POST = withAuth(
|
||||
withPermission('document_templates', 'manage', async (req, _ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, previewAdminTemplateSchema);
|
||||
|
||||
const doc = body.content as unknown as TipTapNode;
|
||||
const sampleData = body.sampleData ?? {};
|
||||
|
||||
// Validate content nodes
|
||||
const unsupported = validateTipTapDocument(doc);
|
||||
if (unsupported.length > 0) {
|
||||
throw new ValidationError(
|
||||
`Content contains unsupported node types: ${unsupported.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Substitute variables in text nodes
|
||||
const substitutedDoc = substituteInDoc(doc, sampleData);
|
||||
|
||||
// Convert to pdfme template + inputs
|
||||
const template = tipTapToPdfmeTemplate(substitutedDoc);
|
||||
const inputs = buildContentInputsFromDoc(substitutedDoc, template);
|
||||
|
||||
const pdfBytes = await generatePdf(template, inputs);
|
||||
const pdfBase64 = Buffer.from(pdfBytes).toString('base64');
|
||||
|
||||
return NextResponse.json({ data: { pdfBase64 } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
|
||||
*/
|
||||
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
|
||||
if (node.type === 'text' && node.text) {
|
||||
return { ...node, text: substituteVariables(node.text, data) };
|
||||
}
|
||||
if (node.content) {
|
||||
return {
|
||||
...node,
|
||||
content: node.content.map((child) => substituteInDoc(child, data)),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}
|
||||
269
src/app/api/v1/admin/users/[id]/permission-overrides/route.ts
Normal file
269
src/app/api/v1/admin/users/[id]/permission-overrides/route.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* GET / PUT per-user permission overrides for the current port.
|
||||
*
|
||||
* GET returns the effective baseline (role + port-role-overrides + residential
|
||||
* toggle) AND the current user-specific override map so the UI can render
|
||||
* three states per leaf: inherit, force-grant, force-deny.
|
||||
*
|
||||
* PUT accepts a Partial<RolePermissions> map (use null at a leaf to clear an
|
||||
* override) and upserts it onto user_permission_overrides for (userId, portId).
|
||||
* Permission `admin.manage_users` is required — same gate as the user-edit
|
||||
* drawer that hosts the matrix.
|
||||
*/
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
portRoleOverrides,
|
||||
roles,
|
||||
userPermissionOverrides,
|
||||
userPortRoles,
|
||||
userProfiles,
|
||||
type RolePermissions,
|
||||
} from '@/lib/db/schema';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Mirrors `RolePermissions` in src/lib/db/schema/users.ts. Used as the
|
||||
* allow-list for the PUT body so a client can't write arbitrary keys
|
||||
* that the resolver would happily merge into the effective permission
|
||||
* map. Keep this in sync when RolePermissions gains a leaf.
|
||||
*/
|
||||
const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
|
||||
clients: new Set(['view', 'create', 'edit', 'delete', 'merge', 'export']),
|
||||
interests: new Set([
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'delete',
|
||||
'change_stage',
|
||||
'override_stage',
|
||||
'generate_eoi',
|
||||
'export',
|
||||
]),
|
||||
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list']),
|
||||
documents: new Set([
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'send_for_signing',
|
||||
'upload_signed',
|
||||
'delete',
|
||||
'manage_folders',
|
||||
]),
|
||||
expenses: new Set(['view', 'create', 'edit', 'delete', 'export', 'scan_receipt']),
|
||||
invoices: new Set(['view', 'create', 'edit', 'delete', 'send', 'record_payment', 'export']),
|
||||
files: new Set(['view', 'upload', 'edit', 'delete', 'manage_folders']),
|
||||
email: new Set(['view', 'send', 'configure_account']),
|
||||
reminders: new Set(['view_own', 'view_all', 'create', 'edit_own', 'edit_all', 'assign_others']),
|
||||
calendar: new Set(['connect', 'view_events']),
|
||||
reports: new Set(['view_dashboard', 'view_analytics', 'export']),
|
||||
document_templates: new Set(['view', 'generate', 'manage']),
|
||||
yachts: new Set(['view', 'create', 'edit', 'delete', 'transfer']),
|
||||
companies: new Set(['view', 'create', 'edit', 'delete']),
|
||||
memberships: new Set(['view', 'manage']),
|
||||
reservations: new Set(['view', 'create', 'activate', 'cancel']),
|
||||
admin: new Set([
|
||||
'manage_users',
|
||||
'view_audit_log',
|
||||
'manage_settings',
|
||||
'manage_webhooks',
|
||||
'manage_reports',
|
||||
'manage_custom_fields',
|
||||
'manage_forms',
|
||||
'manage_tags',
|
||||
'system_backup',
|
||||
'permanently_delete_clients',
|
||||
]),
|
||||
residential_clients: new Set(['view', 'create', 'edit', 'delete']),
|
||||
residential_interests: new Set(['view', 'create', 'edit', 'delete', 'change_stage']),
|
||||
};
|
||||
|
||||
const updateOverridesSchema = z.object({
|
||||
/** Partial<RolePermissions> — passthrough JSON. Validated structurally
|
||||
* by limiting depth + leaf type below. */
|
||||
overrides: z.record(z.string(), z.record(z.string(), z.boolean())).default({}),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||
try {
|
||||
const targetUserId = params.id!;
|
||||
const portId = ctx.portId;
|
||||
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, targetUserId),
|
||||
});
|
||||
if (!profile) throw new NotFoundError('User');
|
||||
|
||||
// Resolve the role's baseline + port-role override (super-admin
|
||||
// edge-case: no role row, so the matrix is empty / not editable).
|
||||
let baseline: RolePermissions | null = null;
|
||||
if (!profile.isSuperAdmin) {
|
||||
const portRole = await db.query.userPortRoles.findFirst({
|
||||
where: and(eq(userPortRoles.userId, targetUserId), eq(userPortRoles.portId, portId)),
|
||||
});
|
||||
if (portRole) {
|
||||
const role = await db.query.roles.findFirst({
|
||||
where: eq(roles.id, portRole.roleId),
|
||||
});
|
||||
baseline = (role?.permissions as RolePermissions | null) ?? null;
|
||||
|
||||
const portOverride = await db.query.portRoleOverrides.findFirst({
|
||||
where: and(
|
||||
eq(portRoleOverrides.portId, portId),
|
||||
eq(portRoleOverrides.roleId, portRole.roleId),
|
||||
),
|
||||
});
|
||||
if (baseline && portOverride?.permissionOverrides) {
|
||||
// Cheap structural merge — same shape as helpers.ts's deepMerge.
|
||||
baseline = mergePerms(baseline, portOverride.permissionOverrides);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userOverride = await db.query.userPermissionOverrides.findFirst({
|
||||
where: and(
|
||||
eq(userPermissionOverrides.userId, targetUserId),
|
||||
eq(userPermissionOverrides.portId, portId),
|
||||
),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
baseline,
|
||||
overrides: userOverride?.permissionOverrides ?? {},
|
||||
isSuperAdmin: profile.isSuperAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PUT = withAuth(
|
||||
withPermission('admin', 'manage_users', async (req, ctx, params) => {
|
||||
try {
|
||||
const targetUserId = params.id!;
|
||||
const portId = ctx.portId;
|
||||
|
||||
// CRITICAL: refuse self-target. Without this an admin with
|
||||
// admin.manage_users can grant themselves every permission leaf
|
||||
// (incl. permanently_delete_clients, system_backup, etc.) and the
|
||||
// override layer in withAuth resolves it on the very next request.
|
||||
if (targetUserId === ctx.userId) {
|
||||
throw new ForbiddenError('You cannot edit your own permission overrides.');
|
||||
}
|
||||
|
||||
// Reject overrides for users that aren't actually assigned to this
|
||||
// port — prevents cross-tenant pollution where an admin in port A
|
||||
// writes a row keyed on (userIdFromPortB, portA). The withAuth
|
||||
// resolver scopes lookups to the caller's port so the row would
|
||||
// never apply, but it still consumes a unique slot and confuses
|
||||
// future audits.
|
||||
const targetPortRole = await db.query.userPortRoles.findFirst({
|
||||
where: and(eq(userPortRoles.userId, targetUserId), eq(userPortRoles.portId, portId)),
|
||||
});
|
||||
if (!targetPortRole) {
|
||||
throw new NotFoundError('User not assigned to this port');
|
||||
}
|
||||
|
||||
const { overrides } = await parseBody(req, updateOverridesSchema);
|
||||
|
||||
// Strip anything outside the canonical RolePermissions allow-list.
|
||||
// The Zod schema only enforces shape (string → string → boolean);
|
||||
// here we drop unknown resources/actions so a malicious client
|
||||
// can't seed garbage keys that a future resolver might accidentally
|
||||
// honour.
|
||||
// CALLER-SUPERSET (authz-auditor CRITICAL): an admin with only
|
||||
// `admin.manage_users` previously could grant another user any
|
||||
// permission leaf — including ones they don't hold themselves
|
||||
// (e.g. `permanently_delete_clients`, `system_backup`). Require
|
||||
// every `true` write to be a leaf the caller already has.
|
||||
// Super-admins bypass (they hold all leaves by definition).
|
||||
const callerPerms = ctx.permissions as Record<string, Record<string, boolean>> | null;
|
||||
const sanitized: Record<string, Record<string, boolean>> = {};
|
||||
for (const [resource, actions] of Object.entries(overrides)) {
|
||||
const allowed = ALLOWED_RESOURCE_ACTIONS[resource];
|
||||
if (!allowed) continue;
|
||||
const cleaned: Record<string, boolean> = {};
|
||||
for (const [action, value] of Object.entries(actions)) {
|
||||
if (!allowed.has(action)) continue;
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new ValidationError(
|
||||
`permission override for ${resource}.${action} must be true or false`,
|
||||
);
|
||||
}
|
||||
if (value === true && !ctx.isSuperAdmin) {
|
||||
const callerHas = callerPerms?.[resource]?.[action] === true;
|
||||
if (!callerHas) {
|
||||
throw new ForbiddenError(
|
||||
`You don't hold ${resource}.${action} yourself, so you can't grant it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
cleaned[action] = value;
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) sanitized[resource] = cleaned;
|
||||
}
|
||||
|
||||
const existing = await db.query.userPermissionOverrides.findFirst({
|
||||
where: and(
|
||||
eq(userPermissionOverrides.userId, targetUserId),
|
||||
eq(userPermissionOverrides.portId, portId),
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(userPermissionOverrides)
|
||||
.set({ permissionOverrides: sanitized, updatedAt: new Date() })
|
||||
.where(eq(userPermissionOverrides.id, existing.id));
|
||||
} else {
|
||||
await db.insert(userPermissionOverrides).values({
|
||||
userId: targetUserId,
|
||||
portId,
|
||||
permissionOverrides: sanitized,
|
||||
});
|
||||
}
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'user',
|
||||
entityId: targetUserId,
|
||||
oldValue: { permissionOverrides: existing?.permissionOverrides ?? {} },
|
||||
newValue: { permissionOverrides: sanitized },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: { overrides: sanitized } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/** Local shallow-merge by resource (matches the RolePermissions shape:
|
||||
* one-level-deep map of resource → action map). Same semantics the
|
||||
* withAuth resolver uses; copied here to avoid pulling that file into
|
||||
* a route module. */
|
||||
function mergePerms(
|
||||
base: RolePermissions,
|
||||
patch: Partial<RolePermissions> | Record<string, Record<string, boolean>>,
|
||||
): RolePermissions {
|
||||
const out = { ...(base as unknown as Record<string, Record<string, boolean>>) };
|
||||
for (const [resource, actions] of Object.entries(patch)) {
|
||||
if (!actions) continue;
|
||||
out[resource] = { ...(out[resource] ?? {}), ...(actions as Record<string, boolean>) };
|
||||
}
|
||||
return out as unknown as RolePermissions;
|
||||
}
|
||||
@@ -21,12 +21,19 @@ export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_users', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateUserSchema);
|
||||
const data = await updateUser(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
const data = await updateUser(
|
||||
params.id!,
|
||||
ctx.portId,
|
||||
body,
|
||||
{
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
ctx.permissions as Record<string, Record<string, boolean>> | null,
|
||||
ctx.isSuperAdmin,
|
||||
);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { and, desc, eq, inArray, lt, sql, type SQL } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, sql, type SQL } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
@@ -71,6 +71,3 @@ export const GET = withAuth(
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Suppress lt unused-import lint
|
||||
void lt;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user