Compare commits
445 Commits
refactor/d
...
d15f5509ad
| Author | SHA1 | Date | |
|---|---|---|---|
| d15f5509ad | |||
| 98211066a5 | |||
| 0d9208a052 | |||
| 3b3ac287e0 | |||
| ff5e71092e | |||
| 58940552be | |||
| 202e0b1bc5 | |||
| 7d33e73eef | |||
| d2804de0d1 | |||
| 84468386d9 | |||
| 3e78c2d4ab | |||
| 608641c23b | |||
| e7e498dedd | |||
| 98fe295675 | |||
| f85948488d | |||
| 025648c40b | |||
| 2d0a49e0d1 | |||
| 27f8db4c67 | |||
| 2c57082d8d | |||
| e469b2b6a6 | |||
| 85bd0d82e1 | |||
| 446342aa69 | |||
| b2ba0b4e0a | |||
| 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 | |||
| 1a2d2dd1e1 | |||
| 020aabcb4e | |||
| 2b1024ff7a | |||
| fdb5beb81a | |||
| e2b5898efc | |||
| 6c159a8cac | |||
| f74448c287 | |||
| 2f9bcf00b1 | |||
| 42927482cd | |||
| 8dc16dcd2e | |||
| 60365dc3de | |||
| 5c8c12ba1f | |||
| 3e4d9d6310 | |||
| 267c2b6d1f | |||
| a0e68eb060 | |||
|
|
05babe57a0 | ||
|
|
1a87f28fd4 | ||
|
|
f3143d7561 | ||
|
|
0f648a924b | ||
|
|
b4fb3b2ca6 | ||
|
|
da7ede71d6 | ||
|
|
0a5f085a9e | ||
|
|
c312cd3685 | ||
|
|
59b9e8f177 | ||
|
|
5fc68a5f34 | ||
|
|
a8c6c071e6 | ||
|
|
94331bd6ec | ||
|
|
588f8bc43c | ||
|
|
c5b41ca4b5 | ||
|
|
9890d065f8 | ||
|
|
d2171ea79b | ||
|
|
4592789712 | ||
|
|
758d8628cf | ||
|
|
44db579988 | ||
|
|
7274baf1e1 | ||
|
|
70105715a7 | ||
|
|
472c12280b | ||
|
|
1ae5d88af4 | ||
|
|
8c02f88cbd | ||
|
|
789656bc70 | ||
|
|
fb02f3d5e1 | ||
|
|
e95316bd8a | ||
|
|
d07f1ed5e0 | ||
|
|
f10334683d | ||
|
|
8690352c56 | ||
|
|
9240cf1808 | ||
|
|
adba73fcca | ||
|
|
c60cbf4014 | ||
|
|
f93de75bb5 | ||
|
|
64f0e0a1b8 | ||
|
|
3f6a8aa3b8 | ||
|
|
c90876abad | ||
|
|
8cdee99310 | ||
|
|
d19b74b935 | ||
|
|
1b78eadd36 | ||
|
|
1fb3aa3aeb | ||
|
|
7bd969b41a | ||
|
|
63c4073e64 | ||
|
|
83239104e0 | ||
|
|
4bab6de8be | ||
|
|
4eea4ceff9 | ||
|
|
7854cbabe4 | ||
|
|
d3a6a9beef | ||
|
|
fc7595faf8 | ||
|
|
6a609ecf94 | ||
|
|
cf430d70c3 | ||
|
|
312779c0c5 | ||
|
|
4723994bdc | ||
|
|
c4a41d5f5b | ||
|
|
687a1f1c2f | ||
|
|
ade4c9e77d | ||
|
|
d4b3a1338f | ||
|
|
cf37d09519 | ||
|
|
180912ba9f | ||
|
|
014bbe1923 | ||
|
|
a3e002852b | ||
|
|
312ebf1a88 | ||
|
|
0b8d08b57e | ||
|
|
86372a857f | ||
|
|
b4776b4c3c | ||
|
|
a0091e4ca6 | ||
|
|
249ffe3e4a | ||
|
|
83693dd993 | ||
|
|
15d4849030 | ||
|
|
e00e812199 | ||
|
|
b1e787e55c | ||
|
|
fb1116f1d4 | ||
|
|
5b70e9b04b | ||
|
|
57cbc9a506 | ||
|
|
6e3d910c76 | ||
|
|
ff92a08620 | ||
|
|
05257723f6 | ||
|
|
3017ce4b3a | ||
|
|
a2588f2c4a | ||
|
|
18119644ae | ||
|
|
61e2fbb2db | ||
|
|
05be89ec6f | ||
|
|
8699f81879 | ||
|
|
d62822c284 | ||
|
|
089f4a67a4 | ||
|
|
77ad10ced1 | ||
|
|
e598cc0708 | ||
|
|
f5772ce318 | ||
|
|
49d34e00c8 | ||
|
|
c612bbdfd9 | ||
|
|
872c75f1a1 | ||
|
|
c45aac551d | ||
|
|
9ad1df85d2 | ||
|
|
8e4d2fc5b4 | ||
|
|
78f2f46d41 | ||
|
|
3a9419fe10 | ||
|
|
b703684285 | ||
|
|
a792d9a182 | ||
|
|
d7ec2a8507 | ||
|
|
cb83b09b2d | ||
|
|
7574c3b575 | ||
|
|
bb105f5365 | ||
|
|
caafae15dd | ||
|
|
46c7389930 | ||
|
|
80fc5932be | ||
|
|
b26b87b2fa | ||
|
|
88f76b6b04 | ||
|
|
a32f41b91d | ||
|
|
cf1c8b66db | ||
|
|
596476280d | ||
|
|
e9359fc431 | ||
|
|
4767caec01 | ||
|
|
49d92234dd | ||
|
|
cad55e3565 | ||
|
|
21868ee5fc | ||
|
|
c7ab816c99 | ||
|
|
e40b6c3d99 | ||
|
|
4bcc7f8be6 | ||
|
|
18e5c124b0 | ||
|
|
8b077e1999 | ||
|
|
36b92eb827 | ||
|
|
e2398099c4 | ||
|
|
d364b09885 | ||
|
|
57a099acc4 | ||
|
|
a391934b73 | ||
|
|
e3e0e69c04 | ||
|
|
6af2ac9680 | ||
|
|
a767652d74 | ||
|
|
c824b2df12 | ||
|
|
d197f8b321 | ||
|
|
76a7387dcc | ||
|
|
868b1f40c0 | ||
|
|
dbbd03fd22 | ||
|
|
ba5fb6db5e | ||
|
|
886119cbde | ||
|
|
0d357731ad | ||
|
|
a75d4f5d69 | ||
|
|
0fb7920db5 | ||
|
|
16ad61ce15 | ||
|
|
d080bc52fa | ||
|
|
a653c8e039 | ||
|
|
7e8110b2ff | ||
|
|
9eadaf035e | ||
|
|
bcea28cd71 | ||
|
|
722491a9dd | ||
|
|
6009ccb7de | ||
|
|
71da6e8fdc | ||
|
|
c405124bc3 | ||
|
|
53cbee1d3d | ||
|
|
ac7f1db62c | ||
|
|
5d44f3cfa4 | ||
|
|
d0540dca55 | ||
|
|
0e9c24e222 | ||
|
|
3aba2181dc | ||
|
|
6237ad1567 | ||
|
|
34916d855e | ||
|
|
41ae8a328f | ||
|
|
1ff3160eac | ||
|
|
5698d742d3 | ||
|
|
e6ce265be0 | ||
|
|
19bc2f2a54 | ||
|
|
b0a11f1785 | ||
|
|
3cbf2444fe | ||
|
|
0330be1312 | ||
|
|
210360738d | ||
|
|
4df04e1a58 | ||
|
|
0c3baf04c5 | ||
|
|
79667b24da | ||
|
|
c4fdb29bbe | ||
|
|
38527d71fc | ||
|
|
3fbfba6598 | ||
|
|
e3a835675b | ||
|
|
1b085f81ed | ||
|
|
9f786fbcf3 | ||
|
|
906127a292 | ||
|
|
737b43589b | ||
|
|
fbb1f1f366 |
@@ -1 +0,0 @@
|
||||
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}
|
||||
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
|
||||
|
||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -20,10 +20,41 @@ tsconfig.tsbuildinfo
|
||||
docker-compose.override.yml
|
||||
.remember/
|
||||
.DS_Store
|
||||
eoi/
|
||||
# Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match.
|
||||
/eoi/
|
||||
|
||||
# Brainstorming companion mockup files
|
||||
.superpowers/
|
||||
|
||||
# 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/
|
||||
|
||||
# Sister marketing site — separate Nuxt project, not part of CRM tracking
|
||||
/website/
|
||||
|
||||
# Mobile audit screenshots — generated locally, regenerable
|
||||
/.audit/
|
||||
/.audit-screenshots/
|
||||
|
||||
# Migration script output (CSV reports, transcripts)
|
||||
.migration/
|
||||
|
||||
# Tool caches / runtime state
|
||||
/.claude/
|
||||
/.serena/
|
||||
/ruvector.db
|
||||
|
||||
# Filesystem storage backend root (FilesystemBackend default location)
|
||||
/storage/
|
||||
|
||||
# Private credentials + forensic captures — never commit
|
||||
/private/
|
||||
|
||||
# Local berth-PDF + brochure samples used as upload fixtures during dev.
|
||||
/berth_pdf_example/
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
45
CLAUDE.md
45
CLAUDE.md
@@ -88,14 +88,43 @@ 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 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).
|
||||
- **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.
|
||||
- **NocoDB berth import:** `pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara` re-imports from the legacy NocoDB Berths table. Idempotent: rows where `updated_at > last_imported_at` (the "human edited this since last import" guard) are skipped unless `--force`. Adds `--update-snapshot` to also rewrite `src/lib/db/seed-data/berths.json`. Uses `pg_advisory_xact_lock` so two simultaneous runs serialize. Pure helpers in `src/lib/services/berth-import.ts` are unit-tested.
|
||||
- **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled.
|
||||
- **API response shapes:** Conventional envelope is `{ data: <T> }` for any endpoint that returns content (read OR write). Mutations that return nothing emit `204 No Content` (`new NextResponse(null, { status: 204 })`). Don't use `{ success: true }` for CRM mutations — it was a legacy pattern, normalized away in 2026-05-07. Public portal-auth endpoints are an exception: they return `{ success: true }` because the frontend needs a non-error JSON body to chain on. List/paginated reads return `{ data: <T[]>, total?, hasMore? }` (see `/api/v1/clients` for the shape). Errors always go through `errorResponse(error)` from `@/lib/errors` so request-id propagation and the audit-tier mapping stay uniform.
|
||||
- **Body parsing:** Always use `parseBody(req, schema)` from `@/lib/api/route-helpers` instead of `await req.json(); schema.parse(body)`. The helper returns a uniform 400 with field-level errors that the frontend's `toastError` hook recognizes; raw `req.json` + `schema.parse` produces a generic 500 because the ZodError isn't caught in the same shape.
|
||||
- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. The hook also blocks `.env*` files (including `.env.example`) from being committed; pass them via a separate workflow if needed.
|
||||
|
||||
## Schema migrations during dev
|
||||
@@ -106,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**.
|
||||
@@ -139,6 +172,14 @@ 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.
|
||||
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
|
||||
Phase 0–8 berth-recommender + PDF + send-outs work bundle. Single source
|
||||
of truth for the multi-berth interest model, recommender tier ladder,
|
||||
pluggable storage, per-berth PDF parser, and sales send-out flows.
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -1,16 +1,21 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# Stage 2: Build the application
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
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@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
# 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"]
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
# Stage 1: Install dependencies (dev deps needed for esbuild)
|
||||
FROM node:20-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# Stage 2: Build the worker bundle
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV SKIP_ENV_VALIDATION=1
|
||||
RUN pnpm build:worker
|
||||
|
||||
# Stage 3: Production runner (prod deps only)
|
||||
# Stage 3: Production runner (prod deps only).
|
||||
#
|
||||
# Critical ordering: create the worker user FIRST and chown the workdir
|
||||
# BEFORE pnpm install, so node_modules + lazy-cache directories
|
||||
# (tesseract.js, sharp) are owned by the worker user. Without this, the
|
||||
# previous layout had pnpm install run as root → node_modules root-owned
|
||||
# → tesseract.js / sharp wrote to node_modules/.cache and EACCES'd at
|
||||
# first PDF parse in prod (auditor-K §39).
|
||||
FROM node:20-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
|
||||
COPY --from=builder --chown=worker:nodejs /app/dist/worker.js ./worker.js
|
||||
WORKDIR /app
|
||||
RUN chown -R worker:nodejs /app
|
||||
USER worker
|
||||
COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
COPY --from=builder --chown=worker:nodejs /app/dist/worker.js ./worker.js
|
||||
# Healthcheck — pings Redis from inside the worker container. Without
|
||||
# this, a worker whose Redis connection has silently dropped (BullMQ
|
||||
# rejects new jobs but the Node process is alive) is invisible to
|
||||
# compose / swarm and jobs queue indefinitely (auditor-K §40).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD node -e "const Redis=require('ioredis');const r=new Redis(process.env.REDIS_URL,{maxRetriesPerRequest:1,connectTimeout:3000,lazyConnect:true});r.connect().then(()=>r.ping()).then(()=>{r.disconnect();process.exit(0)}).catch(()=>process.exit(1))" || exit 1
|
||||
CMD ["node", "worker.js"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Submodule client-portal deleted from 84f89f9409
@@ -14,12 +14,27 @@ 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
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
# BullMQ requires `noeviction` — under memory pressure, allkeys-lru
|
||||
# silently drops queue keys and jobs disappear. See post-audit fix F4.
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy noeviction
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
@@ -28,6 +43,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,11 +66,28 @@ 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
|
||||
# Give the SIGTERM handler in src/server.ts time to drain in-flight
|
||||
# HTTP requests, close Socket.io, and disconnect Redis before Docker
|
||||
# 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
|
||||
|
||||
@@ -58,7 +99,19 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# Match the app: BullMQ jobs need time to finish or be released back
|
||||
# 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
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
# BullMQ requires `noeviction` — under memory pressure, allkeys-lru
|
||||
# silently drops queue keys and jobs disappear. See post-audit fix F4.
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy noeviction
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
@@ -40,7 +42,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
733
docs/AUDIT-CATALOG.md
Normal file
733
docs/AUDIT-CATALOG.md
Normal file
@@ -0,0 +1,733 @@
|
||||
# Comprehensive Audit Catalog — 2026-05-15
|
||||
|
||||
Every audit-worthy surface in Port Nimara CRM, organized by area. Each entry is a discrete check we _could_ run. Pick the subset you want to actually execute.
|
||||
|
||||
**Legend:**
|
||||
|
||||
- **Effort:** XS (~minutes) · S (~30 min) · M (~half day) · L (~1+ day)
|
||||
- **Severity if broken:** 🔴 critical · 🟠 high · 🟡 medium · 🟢 cosmetic
|
||||
- **Coverage today:** ✅ confirmed working · ⚠️ partially checked · ❓ unchecked · ❌ known broken (see prior audits)
|
||||
|
||||
---
|
||||
|
||||
## 0. Already-known issues (cross-reference)
|
||||
|
||||
These were caught in the 2026-05-15 sweep (`docs/audit-2026-05-15.md`) but listed here so we don't re-discover them:
|
||||
|
||||
| ID | Issue | Status |
|
||||
| ----- | -------------------------------------------------------------------------- | --------------------- |
|
||||
| A1 | Dashboard activity feed surfaces raw `permission_denied` rows, no label | ❌ unfixed |
|
||||
| A2 | Activity feed renders legacy 9-stage enum values (`deposit_10pct` etc.) | ❌ unfixed |
|
||||
| A3 | react-grab CSP error spam in dev | ❌ unfixed (dev only) |
|
||||
| A4 | New Client form silently rejects when contact row has empty value | ❌ unfixed |
|
||||
| A5 | Socket.IO WebSocket never connects in `pnpm dev` | ❌ unfixed |
|
||||
| A6 | Some DialogContent missing `aria-describedby` | ❌ unfixed |
|
||||
| A8 | Legacy `statusOverrideMode = "auto"` values still in DB | ❌ unfixed |
|
||||
| A9 | Catch-up wizard defaults to "New Enquiry" instead of "EOI" for under_offer | ❌ unfixed |
|
||||
| A16 | File upload at documents-hub root fails with null vs string validator | ❌ unfixed |
|
||||
| A17 | `/api/v1/admin/ports` is super-admin-only but used as bootstrap resolver | ❌ unfixed |
|
||||
| A18 | 404 vs 403 inconsistency on permission denials | ❌ unfixed |
|
||||
| A19 | F27 same-stage PATCH returns 200 + body instead of 204 | ❌ unfixed |
|
||||
| A20 | OwnerPicker Client/Company toggle hidden until popover opens | ❌ unfixed |
|
||||
| A19_b | Portal `/portal/login` shows "unavailable" — scope undefined | ❌ unfixed |
|
||||
|
||||
---
|
||||
|
||||
## 1. Legacy stage enum bleed (the `deposit_10pct` class of bug)
|
||||
|
||||
**Why this matters:** the pipeline was refactored 9 stages → 7 stages but historical data still carries the old enum values in audit logs, soft-deleted rows, and possibly some hard-coded UI lookups. Every place that renders a stage value should map legacy → modern.
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| L-001 | Grep entire `src/` for hard-coded references to legacy stage names: `details_sent`, `in_communication`, `eoi_sent`, `eoi_signed`, `deposit_10pct`, `contract_sent`, `contract_signed`, `completed` (as stage) | S | 🟠 | ❓ |
|
||||
| L-002 | Audit log diff display: does old `pipelineStage` value get human-friendly mapping? | S | 🟡 | ❌ (A2) |
|
||||
| L-003 | Activity feed labels: same mapping needed | S | 🟡 | ❌ (A2) |
|
||||
| L-004 | Email templates: any merge token surfacing raw stage values? | XS | 🟡 | ❓ |
|
||||
| L-005 | Documenso payload (`buildDocumensoPayload`): any stage references? | XS | 🟠 | ❓ |
|
||||
| L-006 | Public berths API: is `status` filter accepting any legacy values? | XS | 🟡 | ❓ |
|
||||
| L-007 | Webhook payloads: do outbound `interest.updated` events use 7-stage or legacy? | S | 🟠 | ❓ |
|
||||
| L-008 | Reports / analytics SQL: are funnel rollups using 7-stage enum exclusively? | M | 🟠 | ❓ |
|
||||
| L-009 | Search FTS indexes: do they include the mapped human stage or the raw enum? | S | 🟡 | ❓ |
|
||||
| L-010 | Notification copy: does "Stage moved to X" use the mapped label? | XS | 🟢 | ❓ |
|
||||
| L-011 | CSV import templates / column mappers: does anyone still accept legacy stage names? | XS | 🟢 | ❓ |
|
||||
| L-012 | Seed data: confirm no legacy stages in current seed (was migrated in `seed-synthetic-data.ts`) | XS | 🟢 | ✅ |
|
||||
| L-013 | Migration safety: would a re-import via NocoDB re-introduce legacy values? | S | 🟠 | ❓ |
|
||||
| L-014 | Status override mode: legacy `"auto"` value (see A8) — same class of bug | XS | 🟢 | ❌ (A8) |
|
||||
| L-015 | Outcome enum: confirm `won` / `lost_*` are the only modern values; no legacy `completed` outcome anywhere | S | 🟡 | ❓ |
|
||||
| L-016 | Lead category enum: any legacy values? | XS | 🟢 | ❓ |
|
||||
| L-017 | Lead source enum: ditto | XS | 🟢 | ❓ |
|
||||
| L-018 | Tenure type enum: ditto | XS | 🟢 | ❓ |
|
||||
| L-019 | Document doc-status sub-states: `sent`, `signed`, `completed`, `expired`, `rejected` — are they consistently applied? | S | 🟡 | ❓ |
|
||||
| L-020 | Reservation/contract status enum: any legacy / deprecated values lingering? | S | 🟡 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 2. Routes — every page reachable and correct
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------- | ------ | -------- | ------------------- |
|
||||
| R-001 | All `/[portSlug]/*` routes return 200 for super-admin (sweep) | S | 🟠 | ⚠️ admin only |
|
||||
| R-002 | All `/[portSlug]/*` routes return 200 or proper 403/redirect for sales-agent | S | 🟠 | ⚠️ partial |
|
||||
| R-003 | All `/[portSlug]/*` routes for viewer | S | 🟡 | ❓ |
|
||||
| R-004 | Cross-port URL access: paste `/port-amador/clients/<port-nimara-uuid>` → expects 404, not silent | XS | 🟠 | ✅ (F17) |
|
||||
| R-005 | Archived entity detail page: 404 with "Restored?" affordance | XS | 🟡 | ❓ |
|
||||
| R-006 | Soft-deleted folder URL: expects 404 / fallback to parent | XS | 🟡 | ❓ |
|
||||
| R-007 | Hard-deleted berth UUID URL (e.g. A1 in port-amador): expects 404 | XS | 🟡 | ❓ |
|
||||
| R-008 | URL-encoded mooring number (`A1` vs `A%201` vs `a1`): canonicalization | XS | 🟡 | ❓ |
|
||||
| R-009 | Trailing slash redirects | XS | 🟢 | ❓ |
|
||||
| R-010 | Query-string preservation across nav (filters, sort, page) | S | 🟡 | ❓ |
|
||||
| R-011 | Browser back/forward state on detail pages (does Tab selection persist?) | S | 🟡 | ❓ |
|
||||
| R-012 | Deep-link with `?folder=<id>` on documents (F25 verified for root, what about deep folder?) | XS | 🟢 | ⚠️ |
|
||||
| R-013 | Deep-link to specific interest tab (`?tab=documents`) | XS | 🟢 | ❓ |
|
||||
| R-014 | Deep-link with filter pre-applied (`/interests?stage=eoi`) | XS | 🟡 | ❓ |
|
||||
| R-015 | typedRoutes enforcement: any string-as-route escapes via `as never` casts that point to non-existent paths? | M | 🟡 | ❓ |
|
||||
| R-016 | Middleware / proxy.ts: public-path allow-list correctness (regex anchors, prefix matches) | S | 🟠 | ❓ |
|
||||
| R-017 | Auth redirect: visiting `/dashboard` while logged-out → `/login?next=...` | XS | 🟠 | ❓ |
|
||||
| R-018 | Post-login redirect honours `next` param | XS | 🟠 | ❓ |
|
||||
| R-019 | Portal routes when `client_portal_enabled=false`: gate page (verified A19_b) | XS | 🟢 | ✅ |
|
||||
| R-020 | Portal routes when `client_portal_enabled=true`: dashboard, docs, activate flows | S | 🟠 | ❓ |
|
||||
| R-021 | `/setup` bootstrap flow on fresh DB (no super admin yet) | M | 🔴 | ❓ (F1 fixed proxy) |
|
||||
| R-022 | Reset-password token validity + expiry | S | 🟠 | ❓ |
|
||||
| R-023 | Set-password (first-time after invite) flow | S | 🟠 | ❓ |
|
||||
| R-024 | Portal activate via `#token` fragment | S | 🟠 | ❓ |
|
||||
| R-025 | API routes that should be HEAD-cacheable (public/berths) return correct cache headers | S | 🟢 | ❓ |
|
||||
| R-026 | Public health: anonymous mode minimal payload | XS | 🟡 | ❓ |
|
||||
| R-027 | Public health: secret mode full payload | XS | 🟡 | ❓ |
|
||||
| R-028 | OPTIONS preflight on API routes (CORS) | XS | 🟡 | ❓ |
|
||||
| R-029 | API rate-limit headers on auth endpoints | XS | 🟡 | ❓ |
|
||||
| R-030 | `/api/v1/me` returns expected user shape | XS | 🟢 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 3. UX consistency — every list, detail, form
|
||||
|
||||
### 3a. Empty / loading / error states
|
||||
|
||||
| ID | Surface | Effort | Severity | Coverage |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-001 | Clients list: empty state copy + CTA | XS | 🟢 | ❓ |
|
||||
| U-002 | Yachts list: empty state | XS | 🟢 | ❓ |
|
||||
| U-003 | Companies list: empty state | XS | 🟢 | ❓ |
|
||||
| U-004 | Interests list: empty state | XS | 🟢 | ❓ |
|
||||
| U-005 | Berths list: empty state | XS | 🟢 | ❓ |
|
||||
| U-006 | Reservations list: empty state | XS | 🟢 | ❓ |
|
||||
| U-007 | Invoices list: empty state | XS | 🟢 | ❓ |
|
||||
| U-008 | Inbox: empty state | XS | 🟢 | ❓ |
|
||||
| U-009 | Documents hub root: empty state | XS | 🟢 | ❓ |
|
||||
| U-010 | Documents hub folder: empty state (verified earlier) | XS | 🟢 | ✅ |
|
||||
| U-011 | Audit log: empty state (filter to nothing) | XS | 🟢 | ❓ |
|
||||
| U-012 | Reconcile berths: empty state (verified) | XS | 🟢 | ✅ |
|
||||
| U-013 | Recommender: empty result copy (verified F28) | XS | 🟢 | ✅ |
|
||||
| U-014 | All list pages: loading skeleton vs spinner — is the pattern consistent? | S | 🟢 | ❓ |
|
||||
| U-015 | All detail pages: 404 fallback (DetailNotFound) — confirmed for 5 entities, check residential/reservation/invoice/expense | S | 🟡 | ⚠️ |
|
||||
| U-016 | All forms: server-error toast surfaces requestId | S | 🟡 | ❓ |
|
||||
| U-017 | All forms: validation summary at top vs inline messages | S | 🟡 | ❓ |
|
||||
| U-018 | All forms: submit-while-pending state (button disabled + spinner) | S | 🟢 | ❓ |
|
||||
| U-019 | Drag-drop file zone: hover state visible | XS | 🟢 | ❓ |
|
||||
| U-020 | Drag-drop file zone: drop-target overlay on entity folder | XS | 🟢 | ❓ |
|
||||
|
||||
### 3b. Form design
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | --------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-021 | Required-field markers consistent ("\*" vs label suffix vs help text) | S | 🟢 | ❓ |
|
||||
| U-022 | Field-help-text discoverability (tooltip vs always-visible) | S | 🟢 | ❓ |
|
||||
| U-023 | Field-level errors: every field has visible error after blur+submit | M | 🟡 | ❓ |
|
||||
| U-024 | Cancel behaviour: discards or saves draft? | S | 🟡 | ❓ |
|
||||
| U-025 | Unsaved changes warning on dialog dismiss | S | 🟡 | ❓ |
|
||||
| U-026 | Multi-step wizards: persist state across step nav | M | 🟡 | ❓ |
|
||||
| U-027 | Phone E.164 conversion preview | S | 🟢 | ❓ |
|
||||
| U-028 | Currency input: locale-aware separators | S | 🟡 | ❓ |
|
||||
| U-029 | Date picker: keyboard input + calendar both work | S | 🟢 | ❓ |
|
||||
| U-030 | Date range constraint enforcement (start ≤ end) | XS | 🟡 | ❓ |
|
||||
| U-031 | File-type accept attribute matches server magic-byte check | XS | 🟡 | ❓ |
|
||||
| U-032 | File-size limit copy matches server limit | XS | 🟢 | ❓ |
|
||||
| U-033 | Combobox keyboard nav (↑↓, Enter, Esc, type-ahead) | S | 🟢 | ❓ |
|
||||
| U-034 | Multi-select chip removal (X button + backspace) | S | 🟢 | ❓ |
|
||||
| U-035 | Tag colour-picker: contrast check | XS | 🟢 | ❓ |
|
||||
| U-036 | "Save changes" copy consistency (vs "Update" vs "Save") | S | 🟢 | ❓ |
|
||||
| U-037 | Inline-edit save trigger (blur vs Enter vs explicit save) | S | 🟢 | ❓ |
|
||||
| U-038 | Inline-edit cancel (Esc reverts) | XS | 🟢 | ❓ |
|
||||
| U-039 | Inline-tag-editor: tab order across the chip strip | XS | 🟢 | ❓ |
|
||||
|
||||
### 3c. Tables / lists / filters
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-040 | Sort direction indicator on column header | XS | 🟢 | ❓ |
|
||||
| U-041 | Multi-column sort (shift-click) | S | 🟢 | ❓ |
|
||||
| U-042 | Filter chips dismissable via X | XS | 🟢 | ❓ |
|
||||
| U-043 | "Clear all filters" button presence | XS | 🟢 | ❓ |
|
||||
| U-044 | Pagination: page size selector | XS | 🟢 | ❓ |
|
||||
| U-045 | Pagination: jump-to-page | XS | 🟢 | ❓ |
|
||||
| U-046 | Pagination: total count accuracy with filters | XS | 🟡 | ❓ |
|
||||
| U-047 | Row selection: select-all-page vs select-all-filtered | S | 🟡 | ❓ |
|
||||
| U-048 | Bulk action toolbar appearance + dismiss | S | 🟢 | ❓ |
|
||||
| U-049 | Sticky header on scroll | XS | 🟢 | ❓ |
|
||||
| U-050 | Column resize / reorder / show-hide persistence | S | 🟢 | ❓ |
|
||||
| U-051 | Virtual list performance with 1000+ rows | M | 🟡 | ❓ |
|
||||
| U-052 | CSV export of current view (respects filters + columns) | S | 🟡 | ❓ |
|
||||
| U-053 | Sorted-by-relevance vs sorted-by-date default | XS | 🟢 | ❓ |
|
||||
|
||||
### 3d. Badges, icons, colours
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ----------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-054 | Stage badge palette: 7 stages each have a distinct, consistent colour | XS | 🟢 | ❓ |
|
||||
| U-055 | Outcome badge: won = green, lost\_\* = red shades, distinct enough | XS | 🟢 | ❓ |
|
||||
| U-056 | Berth status pill: available/under_offer/sold colour consistency | XS | 🟢 | ✅ |
|
||||
| U-057 | Document status pill: draft/sent/partial/completed/expired/cancelled/rejected | XS | 🟢 | ❓ |
|
||||
| U-058 | "Manual" chip on berth list (F67 phase 2) | XS | 🟢 | ✅ |
|
||||
| U-059 | Icon usage: Lucide-only — no decorative unicode glyphs (memory: avoid emoji) | S | 🟡 | ⚠️ |
|
||||
| U-060 | Button hierarchy: primary/secondary/ghost/destructive used consistently | S | 🟢 | ❓ |
|
||||
| U-061 | Destructive actions colour-coded red | XS | 🟡 | ❓ |
|
||||
| U-062 | Loading spinner sizing consistent (size-3.5 vs size-4 vs animate-spin) | S | 🟢 | ❓ |
|
||||
| U-063 | Tooltip delay + position consistency | S | 🟢 | ❓ |
|
||||
| U-064 | Status pill withDot vs no dot: is the rule consistent? | XS | 🟢 | ❓ |
|
||||
|
||||
### 3e. Modal / sheet / drawer doctrine
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ------------------------------------------------------------------------------ | ------ | -------- | -------- |
|
||||
| U-065 | Sheet used for forms + previews on desktop AND mobile (per CLAUDE.md doctrine) | S | 🟡 | ❓ |
|
||||
| U-066 | Vaul Drawer only used for mobile-bottom-sheet (only `MoreSheet` qualifies) | XS | 🟢 | ❓ |
|
||||
| U-067 | AlertDialog used for destructive confirmations | XS | 🟢 | ❓ |
|
||||
| U-068 | Dialog used for short interactive forms (new yacht, catch-up, won-dialog) | XS | 🟢 | ❓ |
|
||||
| U-069 | Esc closes all overlays consistently | XS | 🟢 | ❓ |
|
||||
| U-070 | Click-outside closes / doesn't close: rule consistent | S | 🟡 | ❓ |
|
||||
| U-071 | Focus trap inside overlays | S | 🟠 | ❓ |
|
||||
| U-072 | Focus restoration to trigger element on close | S | 🟡 | ❓ |
|
||||
|
||||
### 3f. Toasts / feedback
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | -------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-073 | Toast position consistent (top-right, sonner config) | XS | 🟢 | ✅ |
|
||||
| U-074 | Success toast on every mutation (create, update, archive, delete, restore) | M | 🟡 | ⚠️ |
|
||||
| U-075 | Error toast includes copyable requestId | S | 🟡 | ⚠️ |
|
||||
| U-076 | Toast timing (auto-dismiss vs persistent for errors) | XS | 🟢 | ❓ |
|
||||
| U-077 | Multiple toasts stack vs replace | XS | 🟢 | ❓ |
|
||||
|
||||
### 3g. Accessibility / keyboard
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ---------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-078 | Tab order natural on each form | M | 🟡 | ❓ |
|
||||
| U-079 | All icons inside buttons have `aria-label` or sibling text | S | 🟡 | ❓ |
|
||||
| U-080 | All `<img>` have alt | XS | 🟡 | ❓ |
|
||||
| U-081 | Heading hierarchy (h1 → h2 → h3, no skips) | S | 🟢 | ❓ |
|
||||
| U-082 | Color contrast WCAG AA (4.5:1 body, 3:1 large) | M | 🟡 | ❓ |
|
||||
| U-083 | Focus rings visible on all interactive elements | S | 🟡 | ❓ |
|
||||
| U-084 | Skip-to-content link | XS | 🟢 | ❓ |
|
||||
| U-085 | Reduced-motion media query honoured | S | 🟢 | ❓ |
|
||||
| U-086 | `aria-describedby` set on DialogContent (A6) | S | 🟡 | ❌ |
|
||||
| U-087 | Live regions for async updates (toast, notification count) | S | 🟢 | ❓ |
|
||||
| U-088 | Form errors announced to screen readers | S | 🟡 | ❓ |
|
||||
| U-089 | Touch target min 44×44px on mobile | S | 🟡 | ❓ |
|
||||
|
||||
### 3h. Mobile-specific UX
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ----------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| U-090 | Bottom-tab nav reachable on every page | XS | 🟢 | ✅ |
|
||||
| U-091 | Mobile topbar shows correct title via `useMobileChrome` | S | 🟢 | ⚠️ |
|
||||
| U-092 | More sheet contains every nav item not on bottom bar | XS | 🟡 | ❓ |
|
||||
| U-093 | Search overlay covers viewport on tap | XS | 🟢 | ❓ |
|
||||
| U-094 | iOS safe-area-inset-top / bottom respected | S | 🟡 | ❓ |
|
||||
| U-095 | Pull-to-refresh: present or absent? (consistency) | XS | 🟢 | ❓ |
|
||||
| U-096 | Camera capture on file upload (image\* mime type triggers camera) | S | 🟢 | ❓ |
|
||||
| U-097 | Soft keyboard occlusion on form input (visualViewport handling) | S | 🟡 | ❓ |
|
||||
| U-098 | Long-press menu absence (not native iOS overrides) | XS | 🟢 | ❓ |
|
||||
| U-099 | Sheet side="right" responsiveness | XS | 🟢 | ❓ |
|
||||
| U-100 | Mobile bottom tab active-state highlight | XS | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 4. Sales workflows — every end-to-end path
|
||||
|
||||
### 4a. Happy paths
|
||||
|
||||
| ID | Flow | Effort | Severity | Coverage |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| W-001 | Create client → create interest → link yacht → advance to EOI → send EOI → receive webhook → auto-advance to Reservation → record deposit → auto-advance to Deposit Paid → send contract → mark contract signed → mark won | L | 🔴 | ⚠️ |
|
||||
| W-002 | Multi-berth interest: link 3 berths, mark one primary, send EOI bundle with range formatter | M | 🟠 | ❓ |
|
||||
| W-003 | Company-owned yacht: company → membership → yacht owned by company → interest | M | 🟠 | ❓ |
|
||||
| W-004 | Residential client + residential interest end-to-end | M | 🟡 | ❓ |
|
||||
| W-005 | Public berth inquiry → admin/inquiries triage → create client via prefill | M | 🟠 | ❓ |
|
||||
| W-006 | Catch-up wizard from berth list row-menu | S | 🟠 | ⚠️ |
|
||||
| W-007 | Catch-up wizard from reconcile queue (verified) | S | 🟢 | ✅ |
|
||||
| W-008 | Mark won → reopen → outcome cleared toast (F26) | XS | 🟢 | ⚠️ |
|
||||
| W-009 | Mark lost (each lost reason) | S | 🟢 | ❓ |
|
||||
| W-010 | Mark externally signed | S | 🟡 | ❓ |
|
||||
|
||||
### 4b. Edge cases
|
||||
|
||||
| ID | Flow | Effort | Severity | Coverage |
|
||||
| ----- | ---------------------------------------------------------------------- | ------ | -------- | --------- |
|
||||
| W-011 | Try to leave Enquiry without yacht → F23 inline prereq picker fires | XS | 🟢 | ✅ |
|
||||
| W-012 | Try forbidden transition (e.g. Reservation → Enquiry) without override | XS | 🟡 | ❓ |
|
||||
| W-013 | Override transition: requires reason ≥ 5 chars | XS | 🟡 | ❓ |
|
||||
| W-014 | Override transition: insufficient permission → blocked tooltip | XS | 🟡 | ❓ |
|
||||
| W-015 | Rewind to enquiry with linked berths → unlink-or-keep prompt | S | 🟡 | ❓ |
|
||||
| W-016 | Same-stage write (F27): expects 204 | XS | 🟢 | ❌ (A19) |
|
||||
| W-017 | Concurrent stage edits (two browser tabs) | M | 🟡 | ❓ |
|
||||
| W-018 | Stage transition emits audit log + realtime event | S | 🟡 | ❓ |
|
||||
| W-019 | Auto-advance via berth-rule on `deposit_received` | S | 🟠 | ❓ |
|
||||
| W-020 | Auto-advance via Documenso webhook (`DOCUMENT_SIGNED`) | S | 🟠 | ❓ |
|
||||
| W-021 | Webhook arrives twice (idempotency) | S | 🟠 | ✅ (R2-G) |
|
||||
| W-022 | Webhook with v2 envelope shape | S | 🟠 | ❓ |
|
||||
| W-023 | Webhook lowercase-dotted event name → forward-compat | XS | 🟢 | ❓ |
|
||||
| W-024 | Webhook with wrong secret → 401 + rate limit | S | 🟠 | ❓ |
|
||||
| W-025 | Berth unlink mid-EOI → rule fires? | S | 🟡 | ❓ |
|
||||
| W-026 | Yacht reassignment mid-deal | S | 🟡 | ❓ |
|
||||
| W-027 | Client merge (duplicate dedup) — interest carry-over | M | 🟠 | ❓ |
|
||||
| W-028 | Recommender on 0ft yacht (empty dims) | XS | 🟢 | ❓ |
|
||||
| W-029 | Recommender on 300ft yacht (no matching berth) | XS | 🟢 | ✅ (F28) |
|
||||
| W-030 | Recommender weight tuning re-ranks | S | 🟡 | ❓ |
|
||||
| W-031 | Recommender fallthrough policy (cooldown after lost) | M | 🟡 | ❓ |
|
||||
| W-032 | Recommender tier ladder A/B/C/D classification | M | 🟠 | ❓ |
|
||||
| W-033 | Heat scoring weights (recency, furthest stage, count, EOI count) | M | 🟡 | ❓ |
|
||||
| W-034 | Reservation cancel mid-flow | S | 🟡 | ❓ |
|
||||
| W-035 | EOI document expiry | S | 🟡 | ❓ |
|
||||
| W-036 | Contract sent + bounced email | S | 🟡 | ❓ |
|
||||
| W-037 | Reminder snooze / dismiss | S | 🟢 | ❓ |
|
||||
| W-038 | Reminder digest delivery | M | 🟢 | ❓ |
|
||||
| W-039 | Default-owner auto-assign on new interest | XS | 🟢 | ❓ |
|
||||
| W-040 | Reassignment notification email | S | 🟢 | ❓ |
|
||||
| W-041 | Cascading invites (secondary signers) | M | 🟠 | ❓ |
|
||||
| W-042 | Field-level signing verification | M | 🟡 | ❓ |
|
||||
| W-043 | Voice-note attach on activity | S | 🟢 | ❓ |
|
||||
| W-044 | Quick-template log entry | S | 🟢 | ❓ |
|
||||
| W-045 | Note add / edit / delete (polymorphic across entities) | S | 🟢 | ❓ |
|
||||
| W-046 | Tag add via inline-tag-editor (verified F16 inline create flow) | XS | 🟢 | ⚠️ |
|
||||
| W-047 | Tag delete cascade (remove tag from all entities) | S | 🟡 | ❓ |
|
||||
| W-048 | Bulk archive (clients) | S | 🟡 | ❓ |
|
||||
| W-049 | Bulk archive (interests) | S | 🟡 | ❓ |
|
||||
| W-050 | Restore archived (any entity) | S | 🟡 | ❓ |
|
||||
| W-051 | Hard-delete request (GDPR Article 17) | M | 🟠 | ❓ |
|
||||
| W-052 | GDPR export download | M | 🟠 | ✅ (R2-O) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Admin workflows
|
||||
|
||||
| ID | Flow | Effort | Severity | Coverage |
|
||||
| ------ | ---------------------------------------------------------------------------- | ------ | -------- | --------------- |
|
||||
| AD-001 | Role create + permission edit | S | 🟠 | ❓ |
|
||||
| AD-002 | Per-port role override | S | 🟠 | ❓ |
|
||||
| AD-003 | User invite send + email delivered | M | 🟠 | ❓ |
|
||||
| AD-004 | Invite accept + activate (token in #fragment) | S | 🟠 | ❓ |
|
||||
| AD-005 | Invitation revoke / resend | XS | 🟡 | ❓ |
|
||||
| AD-006 | User edit (display name, residential access toggle) | XS | 🟢 | ❓ |
|
||||
| AD-007 | User deactivate | S | 🟠 | ❓ |
|
||||
| AD-008 | System settings key update | XS | 🟡 | ❓ |
|
||||
| AD-009 | Branding logo upload + render in email templates | S | 🟢 | ❓ |
|
||||
| AD-010 | Branding primary colour propagation | S | 🟢 | ❓ |
|
||||
| AD-011 | Document template create with merge tokens | S | 🟠 | ❓ |
|
||||
| AD-012 | Template merge field validation (unknown token rejected) | XS | 🟢 | ❓ |
|
||||
| AD-013 | Email template subject preview / override | S | 🟢 | ❓ |
|
||||
| AD-014 | Tag create + colour pick + delete | XS | 🟢 | ✅ |
|
||||
| AD-015 | Vocabulary list edit (interest temperatures, etc) | S | 🟢 | ❓ |
|
||||
| AD-016 | Custom field add (text, number, select, date) | S | 🟡 | ❓ |
|
||||
| AD-017 | Custom field retrofit on existing rows | S | 🟡 | ❓ |
|
||||
| AD-018 | Webhook create + secret rotate | S | 🟠 | ❓ |
|
||||
| AD-019 | Webhook delivery log + retry | S | 🟡 | ❓ |
|
||||
| AD-020 | Brochure upload + magic-byte check | S | 🟡 | ❓ |
|
||||
| AD-021 | Brochure default toggle (partial unique index) | S | 🟡 | ❓ |
|
||||
| AD-022 | Brochure archive | XS | 🟢 | ❓ |
|
||||
| AD-023 | Per-berth PDF upload + parse | M | 🟠 | ❓ |
|
||||
| AD-024 | Per-berth PDF version rollback | S | 🟡 | ❓ |
|
||||
| AD-025 | OCR parse confidence threshold + AI parse fallback | M | 🟡 | ❓ |
|
||||
| AD-026 | NocoDB import: --apply, --force, --update-snapshot | M | 🟠 | ❓ |
|
||||
| AD-027 | NocoDB import idempotency (re-run after no changes) | S | 🟡 | ❓ |
|
||||
| AD-028 | NocoDB import vs human-edited row skip (updated_at > last_imported_at) | S | 🟡 | ❓ |
|
||||
| AD-029 | Bulk berth add wizard end-to-end | S | 🟠 | ⚠️ (loads only) |
|
||||
| AD-030 | CSV import (clients) — column mapper | M | 🟠 | ❓ |
|
||||
| AD-031 | CSV import (yachts) | M | 🟡 | ❓ |
|
||||
| AD-032 | CSV import error report (rejected rows) | S | 🟡 | ❓ |
|
||||
| AD-033 | Duplicates queue review + merge | M | 🟠 | ❓ |
|
||||
| AD-034 | Duplicates queue: false-positive dismiss | XS | 🟢 | ❓ |
|
||||
| AD-035 | Audit log search/FTS — text query | S | 🟡 | ❓ |
|
||||
| AD-036 | Audit log filter by action / entity / user / date range | S | 🟡 | ❓ |
|
||||
| AD-037 | Audit log diff display (old vs new) | S | 🟡 | ❓ |
|
||||
| AD-038 | Audit log mask of sensitive fields (passwords, tokens) | S | 🟠 | ❓ |
|
||||
| AD-039 | Backup status read | XS | 🟢 | ❓ |
|
||||
| AD-040 | Storage backend swap dry-run (filesystem ↔ s3) | M | 🟠 | ❓ |
|
||||
| AD-041 | Multi-node deployment refuses filesystem backend | XS | 🟠 | ❓ |
|
||||
| AD-042 | Documenso health check Test button (v1 + v2) | S | 🟠 | ❓ |
|
||||
| AD-043 | Documenso API version toggle per-port | S | 🟠 | ❓ |
|
||||
| AD-044 | Documenso signing-order setting (parallel/sequential) | S | 🟡 | ❓ |
|
||||
| AD-045 | Documenso redirect URL setting | XS | 🟢 | ❓ |
|
||||
| AD-046 | AI provider credentials test | S | 🟡 | ❓ |
|
||||
| AD-047 | Receipt OCR config + retry on bad parse | M | 🟡 | ❓ |
|
||||
| AD-048 | Send-from account config + encrypted secret roundtrip | M | 🟠 | ❓ |
|
||||
| AD-049 | Bounce monitoring (IMAP probe + dev-imap-probe script) | M | 🟡 | ❓ |
|
||||
| AD-050 | Reminders default behaviour + digest window edit | S | 🟢 | ❓ |
|
||||
| AD-051 | Residential pipeline stages edit + reassignment on stage removal | M | 🟡 | ❓ |
|
||||
| AD-052 | Qualification criteria reorder (DnD) | S | 🟢 | ❓ |
|
||||
| AD-053 | Berth rules engine config (7 triggers, 3 modes) | M | 🟠 | ❓ |
|
||||
| AD-054 | Recommender weights tune | S | 🟡 | ❓ |
|
||||
| AD-055 | Onboarding checklist progression | S | 🟢 | ❓ |
|
||||
| AD-056 | Reports: pipeline funnel, occupancy timeline, revenue breakdown, lead source | S | 🟡 | ❓ |
|
||||
| AD-057 | Forms: form template create + public submission roundtrip | M | 🟠 | ❓ |
|
||||
| AD-058 | Inquiry inbox triage → convert to client | M | 🟠 | ❓ |
|
||||
| AD-059 | Website analytics (Umami) config | S | 🟢 | ❓ |
|
||||
| AD-060 | Queue monitoring dashboard (BullMQ stats) | XS | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 6. Multi-tenancy (port isolation)
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | -------------------------------------------------------------------- | ------ | -------- | --------- |
|
||||
| MT-01 | GET /api/v1/clients/<other-port-uuid> with X-Port-Id=this-port → 404 | XS | 🟠 | ✅ (R2-N) |
|
||||
| MT-02 | PATCH /api/v1/interests/<other-port-uuid> → 404 | XS | 🟠 | ❓ |
|
||||
| MT-03 | Berth recommender cross-port leak guard (entry + SQL CTE) | S | 🔴 | ✅ |
|
||||
| MT-04 | Document folder defense-in-depth port_id filter on every join | S | 🟠 | ❓ |
|
||||
| MT-05 | Audit log scope per port | XS | 🟠 | ❓ |
|
||||
| MT-06 | Webhook subscriptions scoped to port | XS | 🟠 | ❓ |
|
||||
| MT-07 | System settings per-port | XS | 🟡 | ❓ |
|
||||
| MT-08 | Tags scoped to port | XS | 🟡 | ❓ |
|
||||
| MT-09 | Custom fields scoped to port | XS | 🟡 | ❓ |
|
||||
| MT-10 | Vocabularies scoped to port | XS | 🟡 | ❓ |
|
||||
| MT-11 | Seed runs idempotent across ports | S | 🟡 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 7. Security
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ---- | ---------------------------------------------------------- | ------ | -------- | --------- |
|
||||
| S-01 | XSS via client.fullName render (verified ✓) | XS | 🟠 | ✅ |
|
||||
| S-02 | XSS via tag.name | XS | 🟠 | ❓ |
|
||||
| S-03 | XSS via note.content (markdown) | S | 🟠 | ❓ |
|
||||
| S-04 | XSS via email body markdown (verified) | S | 🟠 | ✅ (R2-I) |
|
||||
| S-05 | SQL injection via search query | S | 🔴 | ❓ |
|
||||
| S-06 | Path traversal in folder name | S | 🟠 | ❓ |
|
||||
| S-07 | Path traversal in file name | XS | 🟠 | ❓ |
|
||||
| S-08 | SSRF via attachment URL or webhook target | S | 🟠 | ❓ |
|
||||
| S-09 | Open redirect on `next` param | XS | 🟠 | ❓ |
|
||||
| S-10 | CSRF on state-changing requests (proxy.ts checks) | S | 🟠 | ❓ |
|
||||
| S-11 | Cookie flags: HttpOnly, Secure, SameSite | XS | 🟠 | ❓ |
|
||||
| S-12 | CSP headers (production) | S | 🟡 | ❓ |
|
||||
| S-13 | CORS allow-list narrow | XS | 🟡 | ❓ |
|
||||
| S-14 | Rate limit on login (verified F7) | XS | 🟠 | ✅ |
|
||||
| S-15 | Rate limit on forget-password | XS | 🟠 | ✅ |
|
||||
| S-16 | Rate limit on file upload | S | 🟡 | ❓ |
|
||||
| S-17 | Session fixation (regen sid on login) | S | 🟠 | ❓ |
|
||||
| S-18 | Token expiry / refresh (better-auth) | S | 🟠 | ❓ |
|
||||
| S-19 | Audit log tamper-resistance (append-only) | S | 🟡 | ❓ |
|
||||
| S-20 | Documenso webhook secret rotation (verified) | S | 🟠 | ✅ |
|
||||
| S-21 | SMTP credential at-rest encryption (AES-256-GCM) | S | 🟠 | ❓ |
|
||||
| S-22 | IMAP credential at-rest encryption | S | 🟠 | ❓ |
|
||||
| S-23 | Storage credential at-rest encryption | S | 🟠 | ❓ |
|
||||
| S-24 | Privilege escalation: viewer → agent → admin paths | M | 🔴 | ❓ |
|
||||
| S-25 | Direct ID enumeration (UUID guess immune) | XS | 🟢 | ✅ (R2) |
|
||||
| S-26 | Audit log read-back of own permission denials | S | 🟢 | ❓ |
|
||||
| S-27 | Magic-byte verification on every uploaded file (verified) | S | 🟠 | ✅ |
|
||||
| S-28 | Filename HTML-escape in download links | XS | 🟡 | ❓ |
|
||||
| S-29 | Bounce-monitor email subject parsing (injection) | S | 🟡 | ❓ |
|
||||
| S-30 | Email body redirect mode never escapes in prod (env guard) | XS | 🟠 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 8. Realtime / sockets
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | -------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| RT-01 | Socket.IO server actually running in dev (A5) | S | 🟡 | ❌ |
|
||||
| RT-02 | Realtime invalidation: interest:updated fires from another tab | S | 🟡 | ❓ |
|
||||
| RT-03 | document:completed event invalidates files | S | 🟡 | ❓ |
|
||||
| RT-04 | folder:created event invalidates document-folders | S | 🟡 | ❓ |
|
||||
| RT-05 | berth:statusChanged event invalidates berths | S | 🟡 | ❓ |
|
||||
| RT-06 | Subscription teardown on unmount (no leaks) | S | 🟡 | ❓ |
|
||||
| RT-07 | Cross-tab broadcast (BroadcastChannel?) | M | 🟢 | ❓ |
|
||||
| RT-08 | Reconnect after server restart | S | 🟡 | ❓ |
|
||||
| RT-09 | Room-level scoping (port:X room) | XS | 🟠 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 9. Performance
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ---- | ------------------------------------------------------------------------ | ------ | -------- | --------------------------- |
|
||||
| P-01 | Web vitals report endpoint accepts beacons (verified — A2 is dev cancel) | XS | 🟢 | ✅ |
|
||||
| P-02 | LCP under 2.5s on dashboard | S | 🟡 | ❓ |
|
||||
| P-03 | CLS under 0.1 | S | 🟢 | ❓ |
|
||||
| P-04 | TTI under 3s | S | 🟡 | ❓ |
|
||||
| P-05 | N+1 detection on interests list (tags / berths / yacht joins) | M | 🟡 | ❓ |
|
||||
| P-06 | DataTable virtual rendering for 1000+ rows | M | 🟡 | ⚠️ (audit-log uses virtual) |
|
||||
| P-07 | Image lazy-load on documents list | XS | 🟢 | ❓ |
|
||||
| P-08 | Bundle size growth budget | S | 🟢 | ❓ |
|
||||
| P-09 | Slow-query log review | M | 🟡 | ❓ |
|
||||
| P-10 | DB connection pool exhaustion behaviour (verified F8 fix landed) | S | 🟠 | ✅ |
|
||||
| P-11 | Memory leak after long session (open same form 50 times) | M | 🟡 | ❓ |
|
||||
| P-12 | Worker queue throughput under load | M | 🟡 | ❓ |
|
||||
| P-13 | Search FTS query plan (uses GIN index?) | S | 🟡 | ❓ |
|
||||
| P-14 | API response size budget (paginated list ≤ 256 KB) | XS | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 10. Documents / files
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ---- | ----------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| D-01 | Upload via drag-drop on hub root (A16 — broken) | XS | 🟠 | ❌ |
|
||||
| D-02 | Upload via drag-drop on entity folder | S | 🟠 | ❓ |
|
||||
| D-03 | Upload via file picker on dialog | XS | 🟠 | ❌ (A16) |
|
||||
| D-04 | PDF preview inline | S | 🟢 | ❓ |
|
||||
| D-05 | Image preview inline (jpg, png, webp, gif) | S | 🟢 | ❓ |
|
||||
| D-06 | Word / Excel: download fallback | XS | 🟢 | ❓ |
|
||||
| D-07 | Signed PDF download from completed workflow | S | 🟠 | ❓ |
|
||||
| D-08 | Folder soft-rescue on delete (children re-parent) | S | 🟠 | ❓ |
|
||||
| D-09 | Folder rename → entity name sync | S | 🟡 | ❓ |
|
||||
| D-10 | Folder move cycle prevention | S | 🟡 | ❓ |
|
||||
| D-11 | Folder permission: system folders immutable through API | S | 🟠 | ❓ |
|
||||
| D-12 | Aggregated entity view (Clients/Companies/Yachts subfolders) | S | 🟡 | ❓ |
|
||||
| D-13 | Hub root view: 3 cards (in-progress, files, completed) | S | 🟢 | ❓ |
|
||||
| D-14 | EntityFolderView: signing-in-progress + files | S | 🟢 | ❓ |
|
||||
| D-15 | "View signing details" link on signed file row | XS | 🟢 | ❓ |
|
||||
| D-16 | Auto-deposit on signing completion (resolves owner via Owner-wins chain) | M | 🟠 | ❓ |
|
||||
| D-17 | listFilesAggregatedByEntity walks Client↔Company↔Yacht reach symmetrically | M | 🟠 | ❓ |
|
||||
| D-18 | Folder URL state with `?folder=<uuid>` (F25 deep folder) | XS | 🟢 | ⚠️ |
|
||||
| D-19 | Concurrent ensureEntityFolder race-safety (partial unique index) | M | 🟡 | ❓ |
|
||||
| D-20 | Magic-byte verification on presign + post-upload paths | S | 🟠 | ✅ |
|
||||
| D-21 | Filename HTML-escape in fallback download link | XS | 🟡 | ❓ |
|
||||
| D-22 | File size > email_attach_threshold_mb → signed-URL link instead of attachment | M | 🟡 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 11. Audit log
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | --------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| AU-01 | Every mutation creates an audit row (sample 10 endpoints) | M | 🟠 | ⚠️ |
|
||||
| AU-02 | Sensitive-field mask works (test: password rotation row) | S | 🟠 | ❓ |
|
||||
| AU-03 | FTS query returns expected results | S | 🟡 | ❓ |
|
||||
| AU-04 | Filter by action: only stage_change shows | XS | 🟢 | ❓ |
|
||||
| AU-05 | Filter by entity type: only berth/interest/etc shows | XS | 🟢 | ❓ |
|
||||
| AU-06 | Filter by user | XS | 🟢 | ❓ |
|
||||
| AU-07 | Filter by date range | XS | 🟢 | ❓ |
|
||||
| AU-08 | Diff display correctly highlights old vs new | S | 🟡 | ❓ |
|
||||
| AU-09 | "Reconcile" event tag visible in metadata | XS | 🟢 | ✅ |
|
||||
| AU-10 | Cascade events grouped or distinct? (e.g. archive client + auto-archive interest) | S | 🟡 | ❓ |
|
||||
| AU-11 | Permission-denied entries render readable (A1) | XS | 🟡 | ❌ |
|
||||
| AU-12 | Audit log export to CSV | S | 🟢 | ❓ |
|
||||
| AU-13 | Outcome-change action tag distinct from generic 'update' (R2-B finding) | S | 🟡 | ❓ |
|
||||
| AU-14 | Tier-mapping (audit_logs.audit_tier_map) — high-tier vs noise tier | S | 🟡 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 12. Email / SMTP / IMAP
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ---------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| EM-01 | Per-port SMTP override picks up | S | 🟠 | ❓ |
|
||||
| EM-02 | Default sales send-from (`sales@portnimara.com`) | XS | 🟢 | ❓ |
|
||||
| EM-03 | Default noreply send-from (`noreply@portnimara.com`) | XS | 🟢 | ❓ |
|
||||
| EM-04 | EMAIL_REDIRECT_TO in dev: subject prefix `[redirected from ...]` | XS | 🟡 | ❓ |
|
||||
| EM-05 | Branded template render (logo, blurred bg, max-w-600) | S | 🟢 | ❓ |
|
||||
| EM-06 | Reply-to override | XS | 🟡 | ❓ |
|
||||
| EM-07 | CC/BCC handling | S | 🟡 | ❓ |
|
||||
| EM-08 | Send rate limit 50/user/hour | XS | 🟡 | ❓ |
|
||||
| EM-09 | Send size > threshold falls back to signed link | M | 🟡 | ❓ |
|
||||
| EM-10 | IMAP bounce probe (`dev-imap-probe.ts`) | M | 🟢 | ❓ |
|
||||
| EM-11 | Bounce subject parse + interest linking | M | 🟡 | ❓ |
|
||||
| EM-12 | Document_sends audit row per send | S | 🟡 | ❓ |
|
||||
| EM-13 | Portal activation email arrives & token works | M | 🟠 | ❓ |
|
||||
| EM-14 | Reset-password email | S | 🟠 | ❓ |
|
||||
| EM-15 | Invite email | M | 🟠 | ❓ |
|
||||
| EM-16 | Reminder digest email | M | 🟢 | ❓ |
|
||||
| EM-17 | EOI generated PDF attached or inline? | S | 🟡 | ❓ |
|
||||
| EM-18 | Outbound email markdown body XSS (verified) | S | 🟠 | ✅ |
|
||||
| EM-19 | Subject override CSP/XSS | S | 🟠 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 13. Integrations
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | --------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| IN-01 | Documenso send EOI via v1 template-generate | M | 🟠 | ❓ |
|
||||
| IN-02 | Documenso v2 envelope/create multipart | M | 🟠 | ❓ |
|
||||
| IN-03 | Documenso distribute (v2) | S | 🟠 | ❓ |
|
||||
| IN-04 | Documenso redistribute / send reminder | S | 🟡 | ❓ |
|
||||
| IN-05 | Documenso downloadSignedPdf | S | 🟠 | ❓ |
|
||||
| IN-06 | Documenso voidDocument | S | 🟡 | ❓ |
|
||||
| IN-07 | Documenso placeFields (v2 field/create-many) | M | 🟡 | ❓ |
|
||||
| IN-08 | Documenso normalizeDocument id ↔ documentId | XS | 🟡 | ❓ |
|
||||
| IN-09 | NocoDB import idempotency | S | 🟡 | ❓ |
|
||||
| IN-10 | S3 / MinIO upload + download | S | 🟠 | ❓ |
|
||||
| IN-11 | S3 presigned URL expiry | XS | 🟡 | ❓ |
|
||||
| IN-12 | Filesystem backend: MULTI_NODE_DEPLOYMENT guard | XS | 🟠 | ❓ |
|
||||
| IN-13 | BullMQ job retry on failure | S | 🟡 | ❓ |
|
||||
| IN-14 | BullMQ Redis `noeviction` policy (verified) | XS | 🟠 | ✅ |
|
||||
| IN-15 | Worker process boot + queue subscribe | S | 🟠 | ❓ |
|
||||
| IN-16 | Public berths API: anon cache headers | XS | 🟢 | ❓ |
|
||||
| IN-17 | Public berths API: status filter (`Under Offer`, `Sold`, `Available`) | S | 🟡 | ❓ |
|
||||
| IN-18 | Public berths single endpoint via mooringNumber (canonical format) | S | 🟡 | ❓ |
|
||||
| IN-19 | Public health anonymous mode (verified A26) | XS | 🟡 | ✅ |
|
||||
| IN-20 | Public health secret mode (verified A26) | XS | 🟡 | ✅ |
|
||||
| IN-21 | OpenAI / AI parser credentials test | S | 🟡 | ❓ |
|
||||
| IN-22 | Tesseract OCR positional heuristics on per-berth PDF | M | 🟡 | ❓ |
|
||||
| IN-23 | Receipt OCR: full receipt parse end-to-end | M | 🟡 | ❓ |
|
||||
| IN-24 | Pdfme PDF generation (any per-port template) | M | 🟡 | ❓ |
|
||||
| IN-25 | PDF-lib AcroForm fill (in-app EOI pathway) | M | 🟠 | ❓ |
|
||||
| IN-26 | EOI merge token expansion (`{{eoi.berthRange}}` etc) | S | 🟠 | ❓ |
|
||||
| IN-27 | Berth-range formatter (single + multi-berth) | S | 🟡 | ❓ |
|
||||
| IN-28 | Portal magic-link consume | S | 🟠 | ❓ |
|
||||
| IN-29 | Umami analytics widget render | XS | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 14. Schema / migration
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| SC-01 | All migrations idempotent (re-run safe) | M | 🟠 | ❓ |
|
||||
| SC-02 | All FKs have ON DELETE behaviour spec'd (CASCADE, SET NULL, RESTRICT) | S | 🟠 | ❓ |
|
||||
| SC-03 | All soft-delete columns indexed (`archivedAt IS NULL`) | S | 🟡 | ❓ |
|
||||
| SC-04 | All search columns have GIN/FTS indexes | S | 🟡 | ❓ |
|
||||
| SC-05 | Composite unique constraints (sibling folder name, default brochure) | S | 🟡 | ❓ |
|
||||
| SC-06 | Partial unique constraints (entity-folder, isPrimary) | S | 🟡 | ❓ |
|
||||
| SC-07 | CHECK constraints (chk_system_folder_shape) | XS | 🟢 | ❓ |
|
||||
| SC-08 | Generated column accuracy (FTS search_text) | S | 🟡 | ❓ |
|
||||
| SC-09 | Column nullability matches Drizzle schema | M | 🟡 | ❓ |
|
||||
| SC-10 | Schema migration restart-after-push (CLAUDE.md gotcha) | XS | 🟠 | ❓ |
|
||||
| SC-11 | Backfill scripts idempotent (`backfill-document-folders.ts`) | S | 🟡 | ❓ |
|
||||
| SC-12 | Legacy enum migration drift (every place that compared against an old value) | M | 🟠 | ❓ |
|
||||
| SC-13 | Currency code enum | XS | 🟡 | ❓ |
|
||||
| SC-14 | Address-component enum | XS | 🟢 | ❓ |
|
||||
| SC-15 | Polymorphic owner: every read-site uses the service helper, not raw column read | M | 🟠 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 15. i18n / l10n
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ---- | ---------------------------------------------- | ------ | -------- | -------- |
|
||||
| L-01 | Currency formatting per locale | S | 🟢 | ❓ |
|
||||
| L-02 | Date formatting per timezone | S | 🟢 | ❓ |
|
||||
| L-03 | Number formatting (1,000.5 vs 1.000,5) | S | 🟢 | ❓ |
|
||||
| L-04 | Plural forms | S | 🟢 | ❓ |
|
||||
| L-05 | RTL support (test with Arabic UA) | S | 🟢 | ❓ |
|
||||
| L-06 | Translation completeness (Phase C status) | M | 🟢 | ❓ |
|
||||
| L-07 | next-intl messages.json coverage | S | 🟢 | ❓ |
|
||||
| L-08 | Server-rendered locale match (Accept-Language) | S | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 16. Browser / device
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | --------------------------------------------------- | ------ | -------- | -------- |
|
||||
| BR-01 | Safari (macOS) primary flows | M | 🟡 | ❓ |
|
||||
| BR-02 | Safari (iOS) primary flows | M | 🟡 | ❓ |
|
||||
| BR-03 | Firefox (latest) | M | 🟢 | ❓ |
|
||||
| BR-04 | Edge (latest) | M | 🟢 | ❓ |
|
||||
| BR-05 | Chrome (latest) — primary | S | 🟢 | ✅ |
|
||||
| BR-06 | iPad (Safari) — tier "click" via computer-use rules | M | 🟢 | ❓ |
|
||||
| BR-07 | Print stylesheet (interest detail, invoice) | S | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 17. Specific behavioral correctness checks
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ---- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ----------- |
|
||||
| B-01 | Berth A1 hard-deleted earlier; confirm no 404 anywhere (interests' linked-berth, public feed, recommender) | M | 🟠 | ❓ |
|
||||
| B-02 | Sara Laurent interest in stage=contract WITHOUT yachtId → render correctness | XS | 🟡 | ❓ |
|
||||
| B-03 | Outcome-set interests filtered from active queries via `activeInterestsWhere` | S | 🟠 | ❓ |
|
||||
| B-04 | EOI bundle range formatter: `A1-A3, B5` for non-contiguous berths | S | 🟡 | ❓ |
|
||||
| B-05 | EOI single-berth case formats to just mooring (`A1`) | XS | 🟢 | ❓ |
|
||||
| B-06 | Activity timeline 7-day window inclusive of today | XS | 🟢 | ✅ (F2 fix) |
|
||||
| B-07 | Heat-scoring tier B only fires for lost/cancelled-only history | M | 🟡 | ❓ |
|
||||
| B-08 | Permission-denied audit row sequencing (does denied API call still log?) | S | 🟡 | ❓ |
|
||||
| B-09 | Same-stage no-op DOES NOT emit audit/socket event (F27) | S | 🟢 | ⚠️ |
|
||||
| B-10 | Documenso webhook with empty body / malformed payload | S | 🟠 | ❓ |
|
||||
| B-11 | Berth status_override_mode transitions through automated → manual → null | M | 🟡 | ❓ |
|
||||
| B-12 | Reconcile clear stamps reason correctly with interest id (verified) | XS | 🟢 | ✅ |
|
||||
| B-13 | Catch-up wizard "contract" stage auto-sets `outcome=won` | S | 🟡 | ❓ |
|
||||
| B-14 | Catch-up wizard surfaces in API audit log as `reconcile_manual` type | XS | 🟢 | ✅ |
|
||||
| B-15 | Mobile shell when initialFormFactor is wrong (Playwright UA = desktop, viewport = mobile) — shell ends up correct after mount | XS | 🟢 | ✅ |
|
||||
| B-16 | Resizing across breakpoint mid-form-edit: state preservation? | S | 🟡 | ❓ |
|
||||
| B-17 | Berths bulk-add wizard: step transitions persist input | M | 🟡 | ❓ |
|
||||
| B-18 | NotesList polymorphic across all 4 entity types (clients, interests, yachts, companies) | S | 🟡 | ❓ |
|
||||
| B-19 | InlineEditableField on every detail page works | M | 🟡 | ❓ |
|
||||
| B-20 | InlineTagEditor: focus management (F45 verified) | S | 🟢 | ⚠️ |
|
||||
| B-21 | OwnerPicker: client+company tabs render correctly (F44 verified) | XS | 🟢 | ✅ |
|
||||
| B-22 | Mark externally signed sets `documentId=null`, `signedAt=now` | S | 🟡 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## 18. Data-clean-up jobs
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| DC-01 | Orphan-blob cleanup on document delete | S | 🟠 | ❓ |
|
||||
| DC-02 | Soft-deleted entities older than X days hard-purged | M | 🟡 | ❓ |
|
||||
| DC-03 | Test entities in DB (per prior audit notes): `Smoke Test Client (renamed)`, `Aurora Marine Holdings Ltd`, `Bad Email Test`, `Phone Test`, `François 🏄 المعتمد`, `CSRF Test`, etc — `db:reseed:synthetic`? | S | 🟢 | ❓ |
|
||||
| DC-04 | Berth A1 hard-deletion in port-amador: was that recovered? | S | 🟡 | ❓ |
|
||||
| DC-05 | Legacy `statusOverrideMode = "auto"` normalize migration | XS | 🟢 | ❌ (A8) |
|
||||
|
||||
---
|
||||
|
||||
## 19. CI / dev experience
|
||||
|
||||
| ID | Check | Effort | Severity | Coverage |
|
||||
| ----- | ---------------------------------------------------------------- | ------ | -------- | -------- |
|
||||
| CI-01 | Husky lint-staged blocks bad commits | XS | 🟢 | ✅ |
|
||||
| CI-02 | `pnpm exec tsc --noEmit` clean | XS | 🟢 | ✅ |
|
||||
| CI-03 | `pnpm lint` zero errors | XS | 🟢 | ✅ |
|
||||
| CI-04 | `pnpm exec vitest run` 1373/1373 pass | S | 🟢 | ✅ |
|
||||
| CI-05 | `pnpm exec playwright test --project=smoke` ~10min | M | 🟢 | ❓ |
|
||||
| CI-06 | `pnpm exec playwright test --project=destructive` | M | 🟢 | ❓ |
|
||||
| CI-07 | `pnpm exec playwright test --project=realapi` (Documenso + IMAP) | M | 🟢 | ❓ |
|
||||
| CI-08 | `pnpm exec playwright test --project=visual` baselines current | S | 🟢 | ❓ |
|
||||
| CI-09 | Gitea CI lint + build-and-push workflows | S | 🟢 | ❓ |
|
||||
| CI-10 | Docker prod build succeeds | M | 🟠 | ❓ |
|
||||
| CI-11 | docker-compose dev startup with all services | S | 🟢 | ❓ |
|
||||
| CI-12 | Pre-commit hook also blocks `.env*` files | XS | 🟢 | ❓ |
|
||||
| CI-13 | `SKIP_ENV_VALIDATION=1` actually bypasses in Docker build | XS | 🟢 | ❓ |
|
||||
|
||||
---
|
||||
|
||||
## Recommendation: priority short-list
|
||||
|
||||
If we want maximum coverage with limited time, I'd pick:
|
||||
|
||||
### Tier 0 — fix what's already known (from A1-A20)
|
||||
|
||||
- A4 (client form silent-fail)
|
||||
- A16 (file upload null vs string)
|
||||
- A17 (/admin/ports bootstrap)
|
||||
- A19 (F27 204 implementation)
|
||||
- A9 (catch-up wizard stage default)
|
||||
- A1/A2 (activity feed labels)
|
||||
|
||||
### Tier 1 — discover new
|
||||
|
||||
- **L-001** through **L-020** — legacy stage enum hunt (the user's specific concern)
|
||||
- **W-001** — full end-to-end happy-path workflow (one full deal)
|
||||
- **U-001** through **U-013** — every empty state surface
|
||||
- **MT-01-11** — multi-tenancy cross-port checks (full sweep)
|
||||
- **AU-01-14** — audit log surface (search, filters, mask, FTS)
|
||||
- **U-021-039** — form design sweep across major forms
|
||||
|
||||
### Tier 2 — fill in coverage
|
||||
|
||||
- **R-001-030** — route correctness
|
||||
- **AD-\* (admin pages)** — at least one mutation per admin section to confirm wiring
|
||||
- **D-01-22** — documents/files end-to-end
|
||||
|
||||
### Tier 3 — depth checks
|
||||
|
||||
- **S-\* (security)** — penetration sweep
|
||||
- **P-\* (performance)** — load + LCP + N+1
|
||||
- **W-011-052** — every edge-case workflow
|
||||
|
||||
---
|
||||
|
||||
**Total surfaces catalogued:** 320+ discrete checks across 19 areas.
|
||||
|
||||
Pick what you want and I'll run it.
|
||||
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`._
|
||||
83
docs/AUDIT-PROGRESS-2026-05-15.md
Normal file
83
docs/AUDIT-PROGRESS-2026-05-15.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Audit Progress Report — 2026-05-15
|
||||
|
||||
Companion to `docs/audit-2026-05-15.md` (findings) and `docs/AUDIT-CATALOG.md` (320+ checks). Tracks what was actually executed in this session and what remains.
|
||||
|
||||
## Fixed and verified (10 of 13 known issues from A1-A20)
|
||||
|
||||
| ID | Fix | Verified |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------- | ------------------------ |
|
||||
| A1 | Dashboard activity feed filters out `permission_denied` entries | ✅ code-reviewed |
|
||||
| A2 | New `LEGACY_STAGE_REMAP` + `canonicalizeStage` / `stageLabelFor` helpers; activity-feed maps legacy → 7-stage | ✅ code-reviewed |
|
||||
| A4 | Client form prunes empty contact rows before zod validation | ✅ Playwright end-to-end |
|
||||
| A6 | file-preview-dialog gets `sr-only` DialogDescription | ✅ code-reviewed |
|
||||
| A8 | Migration 0066 normalizes legacy `statusOverrideMode = 'auto'` → NULL | ✅ migration written |
|
||||
| A9 | Catch-up wizard derives stage from berth status (under_offer → eoi, sold → contract) via stageOverride state | ✅ code-reviewed |
|
||||
| A16 | File upload route coerces FormData null → undefined before zod | ✅ Playwright (201 OK) |
|
||||
| A17 | New `/api/v1/me/ports` endpoint; `apiFetch` uses it as the bootstrap resolver | ✅ Playwright (200 OK) |
|
||||
| A19 | F27 same-stage write returns 204 No Content via STAGE_NOOP sentinel | ✅ Playwright (204) |
|
||||
| A20 | OwnerPicker surfaces "Client / Company" hint chip on trigger when no value set | ✅ code-reviewed |
|
||||
| A18 | Closed as not-a-bug: `/users` doesn't exist (true 404); `/admin/audit` exists and 403s correctly | ✅ analysis |
|
||||
| A3 | **Deferred** — dev-only react-grab CSP noise, cosmetic | ⏭️ skipped |
|
||||
| A5 | **Deferred** — Socket.IO dev noise, requires sidecar service setup | ⏭️ skipped |
|
||||
|
||||
## Legacy stage enum hunt (L-001 done, L-002-L-020 partially)
|
||||
|
||||
| ID | Result |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| L-001 | Grepped entire `src/` — found real bugs in `clients.service.ts` and `berth-recommender.service.ts` rank tables (every modern interest got rank 0) — fixed |
|
||||
| L-002 | Audit log diff display only shows field names (not values) — clean |
|
||||
| L-003 | Activity feed: A2 fix covers this |
|
||||
| L-004 | Email templates: notification-digest.tsx labels `eoi_signed` etc. as notification TYPE (event), not pipeline stage — OK |
|
||||
| L-005 | Documenso payload: no stage refs in `buildDocumensoPayload` |
|
||||
| L-006 | Public berths API: status enum is `available/under_offer/sold` — independent of pipeline stages — OK |
|
||||
| L-007 | Webhook payloads: read-time mapping via `stageLabelFor` recommended for downstream subscribers (not blocking) |
|
||||
| L-008 | Analytics SQL: spot-checked the pipeline-funnel query — uses modern 7-stage enum only ✅ |
|
||||
| L-012 | Seed data: confirmed migrated in `seed-synthetic-data.ts` ✅ |
|
||||
| L-014 | Same as A8 — fixed via migration 0066 |
|
||||
| L-015 | Outcome enum: confirmed `won` + `lost_*` only — no legacy `completed` |
|
||||
| L-019 | Doc-status sub-states: `pending/sent/signed/declined/voided` — consistent ✅ |
|
||||
| — | Stale comment refs to `deposit_10pct` in schema (clients, financial, users) — all updated to modern copy |
|
||||
|
||||
## Routes correctness (R-001..R-030 — partial)
|
||||
|
||||
- R-001 — 13 main `/[portSlug]/*` routes return 200 for super-admin ✅
|
||||
- R-002 — sales-agent: confirmed admin nav hidden + permission gating from earlier audit ✅
|
||||
- R-004 — cross-port deep-link to unknown UUID: returns 200 with `DetailNotFound` rendered (F17) ✅
|
||||
- R-008 — mooring URL canonicalization: `A1`, `a1`, `A%201`, `A001`, `ZZ999` all return 200 (Next renders the page; data fetch surfaces 404 in-page if needed)
|
||||
- R-005, R-006, R-009, R-010, R-011, R-013-R-022 — ❓ unchecked
|
||||
- R-007 — hard-deleted berth A1 in port-amador: route page renders 200, in-page state is the `DetailNotFound` ✅
|
||||
|
||||
## What's NOT done
|
||||
|
||||
These remain unchecked from the catalog:
|
||||
|
||||
- **U-001..U-100 UX consistency sweep** — partial (catch-up wizard tested, OwnerPicker tested). Empty states, form design, tables/lists/filters, badges, modals, mobile UX — needs dedicated session.
|
||||
- **W-001..W-052 sales workflows** — happy path (W-001) NOT walked end-to-end. Reservations, invoices, EOI signing pathway, contract signing, refund handling, GDPR export, etc. all unchecked beyond earlier audits.
|
||||
- **AD-001..AD-060 admin workflows** — only sampled (tag creation, audit log viewing). Role create, invite roundtrip, custom fields retrofit, brochures, per-berth PDFs, NocoDB import, CSV import — unchecked.
|
||||
- **MT-01..MT-11 multi-tenancy** — only the recommender + entry-point checks confirmed earlier. Defense-in-depth port_id filters on every join — sample-checked.
|
||||
- **S-01..S-30 security** — only items previously verified (rate-limit, XSS in client name, magic-byte verification). SQL injection, CSRF, SSRF, privilege escalation, session fixation, CSP headers — unchecked.
|
||||
- **RT-01..RT-09 realtime** — A5 deferred; nothing tested.
|
||||
- **P-01..P-14 performance** — nothing tested.
|
||||
- **D-01..D-22 documents/files** — partial (upload at root verified after A16 fix).
|
||||
- **AU-01..AU-14 audit log surface** — only auto-emit verified.
|
||||
- **EM-01..EM-19 email** — nothing tested.
|
||||
- **IN-01..IN-29 integrations** — nothing new tested.
|
||||
- **SC-01..SC-15 schema** — nothing tested beyond what existing migrations confirm.
|
||||
- **L-1..L-08 i18n/l10n** — nothing tested.
|
||||
- **BR-01..BR-07 browser/device** — only Chrome verified.
|
||||
- **B-01..B-22 behavioral correctness** — partial.
|
||||
- **DC-01..DC-05 data clean-up** — A8 done; others unchecked.
|
||||
- **CI-01..CI-13 CI/dev experience** — tsc/lint/vitest verified per commit; Playwright projects not run; Docker build not tested.
|
||||
|
||||
## Bottom line
|
||||
|
||||
11 of the 13 known issues from yesterday's sweep are fixed and pushed. The biggest discovered fix was the legacy-stage rank tables in clients.service + berth-recommender that were silently broken for every post-9→7-refactor interest. Two dev-only issues (A3, A5) deferred.
|
||||
|
||||
Remaining catalog coverage requires multiple dedicated sessions — there are 300+ unique checks still in `AUDIT-CATALOG.md`. The catalog is the to-do list; pick the next slice you want me to take.
|
||||
|
||||
## Commits in this session
|
||||
|
||||
- `0d9208a` fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20
|
||||
- `9821106` fix(legacy-stage): purge 9-stage enum keys from rank tables and stale copy
|
||||
|
||||
Test suite: 1373/1373 pass · tsc clean · lint clean.
|
||||
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._
|
||||
337
docs/BACKLOG.md
Normal file
337
docs/BACKLOG.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Master backlog index
|
||||
|
||||
**Single source of truth for everything outstanding.** Start here when
|
||||
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-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.
|
||||
|
||||
---
|
||||
|
||||
## A. Documenso build (deferred for later)
|
||||
|
||||
**Source:** [`docs/documenso-build-plan.md`](./documenso-build-plan.md) — full phase plan with locked decisions (Q1–Q10).
|
||||
**Tracker delta:** [`docs/admin-ux-backlog.md`](./admin-ux-backlog.md) — what landed in Phase 1.
|
||||
|
||||
Phase 1 (EOI generate flow polish + APPROVER-as-CC + per-port settings + signing-URL fix) is **DONE** and committed.
|
||||
|
||||
Remaining phases — explicitly back-burnered by the user on 2026-05-07:
|
||||
|
||||
| Phase | Scope | Estimate | Notes |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Phase 2** | Webhook handler enhancement: cascading "your turn" emails, on-completion PDF distribution, token-based recipient matching, idempotency lock | ~3–4h | Schema columns already in place from Phase 1 (`document_signers.invited_at / opened_at / signing_token`, `documents.completion_cc_emails`). |
|
||||
| **Phase 3** | Custom doc upload-to-Documenso: `custom-document-upload.service.ts` + `POST /api/v1/interests/[id]/upload-for-signing` | ~6–8h | Depends on Phase 2 webhook UX in anger before locking the upload UX. |
|
||||
| **Phase 4** | Field placement UI: react-pdf + dnd-kit overlay + auto-detect anchor scanner via pdfjs `getTextContent` | ~10–14h | Largest piece. Plan locked in build-plan Phase 4 — regexes, anchors, type-to-bbox sizing all spelled out. Best done in a focused session with the user watching. |
|
||||
| **Phase 5** | Embedded signing URL emission verification: confirm website's `/sign/<type>/<token>` page handles every signer-role × documentType combination; update `signerMessages` map; apply nginx CORS block from integration audit | ~1–2h | |
|
||||
| **Phase 6** | Polish: auto-send delay, audit-log additions, per-document customisation, document expiration, reminder rate-limit display, failed-webhook recovery UI | each ~2–3h | All deferred until Phases 1–4 ship. |
|
||||
| **Phase 7** | Project Director RBAC — UI binding for the developer-user fields. Add "Linked to CRM user" dropdown in `/admin/documenso/page.tsx`; auto-fill name/email; webhook handler matches against linked user's email for in-CRM signing-status updates. Schema + setting keys (`documenso_developer_user_id`, `documenso_approver_user_id`, `_label`) already in place from Phase 1. | ~1h | Smallest piece; could be picked off independently of Phase 2. |
|
||||
| **Risk #4** | v2 webhook payload audit against a live v2 instance (`payload.documentId` vs `payload.id`, `recipient.token` vs `recipient.recipientId`) before relying on Phase 2 cascading emails | ~1h | Needs a live v2 instance. |
|
||||
|
||||
---
|
||||
|
||||
## B. Custom-fields hardening
|
||||
|
||||
**Source:** [`docs/admin-ux-backlog.md`](./admin-ux-backlog.md) §7.
|
||||
|
||||
- ✅ **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** — 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.
|
||||
|
||||
---
|
||||
|
||||
## C. Audit-final deferred items
|
||||
|
||||
**Source:** [`docs/audit-final-deferred.md`](./audit-final-deferred.md) — pre-merge + post-merge audit findings explicitly carried over.
|
||||
|
||||
The 2026-05-07 backlog sweep landed every small/concrete item. Remaining
|
||||
entries are deferred because they need design decisions, live external
|
||||
instances, or cross-cutting refactors:
|
||||
|
||||
### Deferred — Documenso-related (back-burnered until phases 2-7 land)
|
||||
|
||||
- **Documenso webhook does not enforce port_id on document lookups** — `src/app/api/webhooks/documenso/route.ts:96-148`. Bundle with Documenso Phase 2 (webhook handler enhancement) since they touch the same code.
|
||||
- **Webhook dedup vs per-recipient signed events** — `src/app/api/webhooks/documenso/route.ts:103-110`. Replacing the body-hash dedup with a `(documensoDocumentId, recipientEmail, eventType)` composite unique requires a recipient_email column on `documentEvents`. Bundle with Phase 2.
|
||||
- **v2 voidDocument endpoint shape verification** — `src/lib/services/documenso-client.ts:450-466`. Needs a live Documenso 2.x instance. Bundle with Phase 5.
|
||||
|
||||
### Deferred — pure refactor (no active bug)
|
||||
|
||||
- **Public POST routes bypass service layer** — `src/app/api/public/{interests,website-inquiries,residential-inquiries}/route.ts`. The audit's `userId: null as unknown as string` cast was already cleaned up to a proper `userId: null`. Remaining concern is testability: extract a shared `publicInterestService.create(...)`. Pure ergonomics — no active bug or security issue.
|
||||
|
||||
### Done in 2026-05-08 sweep (latest)
|
||||
|
||||
- ✅ Storage proxy port_id binding: `ProxyTokenPayload` gains optional `p` (port slug) claim; verifier asserts `key.startsWith(${p}/)`. document-sends 24h URLs opt in; other issuers continue working unchanged.
|
||||
- ✅ system_settings index rebuilt with `NULLS NOT DISTINCT` (migration 0047) — global settings are now uniquely keyed by `key` alone. Surfaced + cleaned 65 duplicate `(storage_backend, NULL)` rows that had accumulated from race-prone delete-then-insert patterns.
|
||||
- ✅ All 4 read-then-write systemSettings sites converted to true `onConflictDoUpdate` upserts (ocr-config, settings, residential-stages, ai-budget).
|
||||
- ✅ Response shape standardization: 16 routes converted from `{ success: true }` → `204 No Content`. CLAUDE.md documents the convention.
|
||||
- ✅ `req.json()` → `parseBody()` migration across 9 admin/CRM routes (custom-fields, expenses/export ×3, currency convert, search/recently-viewed, admin/duplicates, berths/pdf-{upload-url,versions,parse-results}). Portal-auth routes intentionally retained `{ success: true }`.
|
||||
- ✅ Custom-field merge tokens: validator accepts `{{custom.<fieldName>}}` shape; resolver in `mergeCustomFieldValues` substitutes from per-port custom_field_definitions + per-entity values for client/interest/berth contexts. Banner updated.
|
||||
- ✅ `/api/v1/files` accepts `companyId` and `yachtId` filters. uploadFile service writes both. file-upload-zone component accepts both props.
|
||||
- ✅ Company Documents tab (CompanyFilesTab) re-enabled and added to company detail tabs.
|
||||
|
||||
### Done in 2026-05-07 sweep (commits in this session)
|
||||
|
||||
- ✅ Partial archived indexes (migration 0046) — `clients`, `interests`, `yachts`, `residential_clients`, `residential_interests`
|
||||
- ✅ `document_sends` interestId port-verification helper
|
||||
- ✅ Custom-fields per-entity permission gate (replaces hardcoded `clients.view/edit`)
|
||||
- ✅ EOI Berth Range warn log (was already in place)
|
||||
- ✅ v1 `placeFields` retry with backoff (was already in place)
|
||||
- ✅ S3 bucket-exists check at boot (was already in place)
|
||||
- ✅ Filesystem dev HMAC fallback warn (was already in place)
|
||||
- ✅ Storage cache fingerprint documentation comment
|
||||
- ✅ AI worker cost ledger writes (was already in place)
|
||||
- ✅ Logger redact paths covering headers, encrypted blobs, two-level nesting (was already in place)
|
||||
- ✅ `loadRecommenderSettings` accepts string `"true"`/`"false"` JSONB booleans
|
||||
- ✅ `renderReceiptHeader` cursor math anchored to captured `baseY`
|
||||
- ✅ Berth PDF apply: silent-drop logging for non-finite numeric coercions
|
||||
- ✅ Saved-views: confirmed by-design owner-only (existing inline doc)
|
||||
- ✅ Alerts ack/dismiss: confirmed by-design port-wide (service correctly bounded)
|
||||
- ✅ Storage admin migration toasts (already in place)
|
||||
- ✅ Invoice send/payment toasts + permission gates (already in place)
|
||||
- ✅ Admin user list edit + remove gates (added remove gate)
|
||||
- ✅ Email threads list skeleton + empty state (already in place)
|
||||
- ✅ Scan page error state for OCR failures (already in place)
|
||||
- ✅ Invoice detail typed (replaced `any` with `InvoiceDetailData` interface)
|
||||
- ✅ All FK indexes called out in audit doc (already in place — audit was stale)
|
||||
- ✅ `documentSends.sentByUserId` FK (already had `.references(...)`)
|
||||
|
||||
### Documented limitations (no action planned)
|
||||
|
||||
- **`berths.current_pdf_version_id` lacks Drizzle FK** — `src/lib/db/schema/berths.ts:83`. The in-line comment fully documents why (circular FK between `berths` ↔ `berth_pdf_versions` makes column-level `.references()` infeasible). FK is enforced via migration 0030. Revisit if Drizzle adds deferred-FK support.
|
||||
- **`systemSettings` schema declares `uniqueIndex` instead of `NULLS NOT DISTINCT`** — Drizzle's `uniqueIndex` builder doesn't surface the flag. Migration 0047 is the source of truth; `db:push` against an empty DB would skip the flag. Same documented-limitation pattern as `berths.current_pdf_version_id`.
|
||||
- **One remaining `req.json()` in admin/custom-fields/[fieldId]** — intentional. The handler inspects raw body to detect `fieldType` mutation attempts; parseBody would lose the raw view. Documented inline.
|
||||
|
||||
---
|
||||
|
||||
## D. Inline TODOs in code (2 remaining)
|
||||
|
||||
| File:line | Note | Status |
|
||||
| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| ~~`client-yachts-tab.tsx:93`~~ | YachtForm preset owner prop | ✅ landed 2026-05-07 (`initialOwner` prop) |
|
||||
| ~~`interest-form.tsx:329`~~ | Include company-owned yachts where client is a member | ✅ landed 2026-05-07 (`yachtOwnerFilter` array filter) |
|
||||
| ~~`interest-form.tsx:330`~~ | "Add new yacht" inline shortcut | ✅ landed 2026-05-07 (Plus button + YachtForm sheet) |
|
||||
| [`src/lib/queue/scheduler.ts:44`](../src/lib/queue/scheduler.ts#L44) | Per-user reminder schedule (override on top of per-port digest) | Placeholder — per-port digest works; revisit when a customer asks for per-user override |
|
||||
| [`src/lib/queue/workers/import.ts:13`](../src/lib/queue/workers/import.ts#L13) | CSV/Excel import worker — entire feature surface | Placeholder — nothing currently enqueues `import` jobs (verified) |
|
||||
|
||||
---
|
||||
|
||||
## E. Hidden / stubbed UI tabs
|
||||
|
||||
- ✅ **Company Documents tab** — landed 2026-05-08. `/api/v1/files` accepts `companyId`+`yachtId` filters; CompanyFilesTab + uploadZone wired through the storage abstraction.
|
||||
- **Berth Waiting List + Maintenance Log tabs** — `src/components/berths/berth-tabs.tsx:346`. Removed entirely; revisit if/when product asks.
|
||||
- **Interest Contract / Reservation tabs** — `src/components/interests/interest-{contract,reservation}-tab.tsx`. Render a "coming soon" friendly card; the real flow is gated on Documenso Phases 2–6.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
not surfaced in §C above were resolved via the `fix(audit): …` commits
|
||||
(`588f8bc`, `94331bd`, `a8c6c07`, `5fc68a5`, `da7ede7`, `c5b41ca`,
|
||||
`b4fb3b2`, `0f648a9`, `c312cd3`, `0a5f085`, `1a87f28`, `f3143d7`,
|
||||
`05babe5`). Keep for historical context:
|
||||
|
||||
- [`audit-comprehensive-2026-05-05.md`](./audit-comprehensive-2026-05-05.md) — pre-merge audit (1 CRIT + 18 HIGH at start)
|
||||
- [`audit-comprehensive-2026-05-06.md`](./audit-comprehensive-2026-05-06.md) — post-merge audit (1 CRIT + 7 HIGH + 10 MED + 7 LOW)
|
||||
- [`audit-frontend-2026-05-06.md`](./audit-frontend-2026-05-06.md) — frontend-only sweep
|
||||
- [`audit-missing-features-2026-05-06.md`](./audit-missing-features-2026-05-06.md) — admin-promised-but-unwired features (V1–V12)
|
||||
- [`audit-permissions-2026-05-06.md`](./audit-permissions-2026-05-06.md) — permission-gate gaps
|
||||
- [`audit-reliability-2026-05-06.md`](./audit-reliability-2026-05-06.md) — transactional integrity / TOCTOU
|
||||
- [`berth-feature-handoff-prompt.md`](./berth-feature-handoff-prompt.md) — berth recommender handoff (shipped, kept as reference)
|
||||
- [`berth-recommender-and-pdf-plan.md`](./berth-recommender-and-pdf-plan.md) — berth recommender + per-berth PDF plan (Phases 0–8 shipped)
|
||||
- [`documenso-integration-audit.md`](./documenso-integration-audit.md) — Documenso integration spec (drives §A)
|
||||
- [`website-refactor.md`](./website-refactor.md) — public website cutover plan
|
||||
305
docs/POST-AUDIT-FIX-PLAN.md
Normal file
305
docs/POST-AUDIT-FIX-PLAN.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Post-Audit Fix Plan
|
||||
|
||||
Generated 2026-05-14 from two rounds of deep Playwright + API audit on `feat/documents-folders` → `main`.
|
||||
|
||||
**Total findings:** 24 fixes + 1 new feature. Grouped by priority. Each entry has impact, file pointer, and effort estimate.
|
||||
|
||||
---
|
||||
|
||||
## TIER 0 — Already Applied in Working Tree (uncommitted)
|
||||
|
||||
Status: **fixed in code, not yet committed**. Commit + push to ship.
|
||||
|
||||
### F1. `/api/v1/bootstrap/*` proxy allow-list (task #22)
|
||||
|
||||
- **Impact:** Cold-start VPS deploy can't bootstrap its first super-admin. `/setup` page calls `/api/v1/bootstrap/status` which 401s; setup form never renders.
|
||||
- **File:** `src/proxy.ts` — added to `PUBLIC_PATHS`.
|
||||
- **Effort:** XS.
|
||||
|
||||
### F2. Interest detail page 500s on every visit (task #25)
|
||||
|
||||
- **Impact:** Sales workflow non-functional. Raw `Date` passed to postgres-js `sql\`${col} >= ${dateVar}\`` template crashes the Bind step.
|
||||
- **File:** `src/lib/services/interests.service.ts:566` — switched to `gte(col, date)`.
|
||||
- **Effort:** XS.
|
||||
|
||||
---
|
||||
|
||||
## TIER 1 — Pre-Deploy Blockers (P1)
|
||||
|
||||
Ship before any real client touches the system.
|
||||
|
||||
### F3. GDPR export 500s — BullMQ rejects job IDs with colons (task #51)
|
||||
|
||||
- **Impact:** GDPR Article 15 right-to-access non-functional. Legal/compliance gate.
|
||||
- **File:** `src/lib/services/gdpr-export.service.ts:113` — change `jobId: \`gdpr-export:${row.id}\`` → `jobId: \`gdpr-export-${row.id}\``.
|
||||
- **Effort:** XS (one char).
|
||||
|
||||
### F4. Redis eviction policy is `allkeys-lru` but BullMQ requires `noeviction` (companion to F3)
|
||||
|
||||
- **Impact:** Under memory pressure, Redis will evict BullMQ keys; jobs disappear silently.
|
||||
- **File:** production Redis config (`maxmemory-policy noeviction`) + the docker-compose redis service.
|
||||
- **Effort:** XS (config).
|
||||
|
||||
### F5. `deleteBerth()` hard-deletes rows instead of soft-archiving (task #65)
|
||||
|
||||
- **Impact:** Permanent data loss on accidental delete. Junction tables CASCADE-vanish. Audit log points to non-existent rows. Public feed could 404 mid-customer-inquiry.
|
||||
- **Files:**
|
||||
- `src/lib/services/berths.service.ts:673-685` — replace `db.delete()` with `set archivedAt = now(), archivedBy = userId, archiveReason = input.reason`.
|
||||
- Add filter `isNull(berths.archivedAt)` to all default berth queries (recommender, public feed, list, dashboard heat).
|
||||
- Add restore endpoint `POST /api/v1/berths/[id]/restore` mirroring the interests pattern.
|
||||
- Require `reason` (min 5 chars) before destructive call.
|
||||
- **Effort:** M.
|
||||
|
||||
### F6. Weak input validation on `/api/v1/clients` (task #50)
|
||||
|
||||
- **Impact:** Email format not validated (bounces silently); whitespace-only names accepted (blank chips everywhere); XSS payload stored verbatim (depends on every render path being safe).
|
||||
- **Files:**
|
||||
- `src/lib/validators/clients.ts` — add `.email()` refinement on contacts where `channel === 'email'`; trim+min(1) on `fullName`; regex-strip control chars + zero-width chars.
|
||||
- Audit every fullName render path for `dangerouslySetInnerHTML` / pdfme / react-pdf / email template merges and ensure escaping.
|
||||
- Apply similar hardening to yachts, companies, interests, notes, berths, reminders (audit all string fields).
|
||||
- **Effort:** S for the obvious zod tweaks, M for the full audit.
|
||||
|
||||
### F7. No rate limiting on login (task #68)
|
||||
|
||||
- **Impact:** Brute force is wide open. 20 wrong-password attempts in a row all returned 401 with no lockout.
|
||||
- **Files:**
|
||||
- `src/lib/auth/` — add a `rateLimit` block to the better-auth config: `{ window: 60, max: 5 }` per IP+email.
|
||||
- Optionally: Redis sliding window via existing ioredis client.
|
||||
- Optionally: per-user lockout table (`auth_lockouts`) after 5 failures, locked 15min.
|
||||
- **Effort:** S.
|
||||
|
||||
### F8. postgres-js pool corruption causes CONNECT_TIMEOUT (task #46)
|
||||
|
||||
- **Impact:** During the audit the dev server twice entered a stuck state where every query 500'd with `CONNECT_TIMEOUT` while the DB was healthy (1/100 connections used). Production VPS will hit this under load.
|
||||
- **Files:**
|
||||
- `src/lib/db/index.ts` — add `connect_timeout: 5`, `max_lifetime: 60 * 60`, `idle_timeout: 30`.
|
||||
- Wrap critical-path queries in retry-on-CONNECT_TIMEOUT logic (one retry, then 503).
|
||||
- Consider pgbouncer in front of postgres for production multi-process deployments.
|
||||
- **Effort:** S for the postgres-js options, M for full pgbouncer.
|
||||
|
||||
---
|
||||
|
||||
## TIER 2 — High Impact Architectural / UX
|
||||
|
||||
Not strictly deploy-blocking, but each one breaks the UX in observable ways every day.
|
||||
|
||||
### F9. Layout-wide duplicate mobile/desktop DOM rendering (task #26)
|
||||
|
||||
- **Impact:** Single highest leverage UX bug. EVERY page mounts BOTH responsive layouts; both Radix Tabs providers are concurrently active with `data-state="active"`. Half my click attempts on tabs/filters/popovers went to the wrong layer. Doubled network requests, doubled component state, doubled a11y landmarks.
|
||||
- **Files:** the responsive shell (likely `src/components/layout/*-shell.tsx` and detail-page wrappers).
|
||||
- **Fix options:** use `useMediaQuery` to mount only one tree; or hoist `<Tabs>` to a single provider and let both layouts consume context.
|
||||
- **Effort:** L (architectural refactor across multiple pages).
|
||||
|
||||
### F10. Archiving a client doesn't cascade-archive their interests (task #66)
|
||||
|
||||
- **Impact:** Orphan refs. Archived clients have active interests; active queries surface them with broken breadcrumbs / silent 404s on drill-in.
|
||||
- **Files:** `src/lib/services/clients.service.ts:archiveClient()` — wrap in transaction, archive open interests too. OR extend `activeInterestsWhere()` to filter on `client.archived_at IS NULL`.
|
||||
- **Effort:** S.
|
||||
|
||||
---
|
||||
|
||||
## TIER 3 — Standard Fixes (P3)
|
||||
|
||||
UX polish + missing entry points. Each is small, but the sum matters.
|
||||
|
||||
### F11. "Mark as won" dialog still says "moves to Completed" (task #27)
|
||||
|
||||
- **Impact:** Stale copy from before the 7-stage refactor. Misleads users.
|
||||
- **File:** `src/components/interests/won-dialog.tsx` (or similar) — update copy to "marks Won; stage stays at <current>".
|
||||
- **Effort:** XS.
|
||||
|
||||
### F12. Activity feed + tab count concatenation (task #23)
|
||||
|
||||
- **Impact:** "Test Person 1interest", "Interests0", "Click Test Co.company" — unprofessional.
|
||||
- **Files:** `src/components/dashboard/activity-feed.tsx` (entity name + type), every detail-page tab count render. Audit log FTS `search_text` should also include entity names.
|
||||
- **Effort:** S.
|
||||
|
||||
### F13. Bulk-add berths wizard has no UI entry point (task #28)
|
||||
|
||||
- **Impact:** Feature built for new-port setup, but invisible. Operator must know the URL.
|
||||
- **Files:** Add a "Bulk add" button next to "New berth" on `/[portSlug]/berths`. Add link on `/admin` landing card.
|
||||
- **Effort:** S.
|
||||
|
||||
### F14. Audit Log page has no UI entry point (task #49)
|
||||
|
||||
- **Impact:** Feature built, no nav link. Discovery requires URL knowledge.
|
||||
- **Files:** Sidebar Admin section — add "Audit Log" entry under `documents` settings or as its own item, gated by `audit_log.view` permission.
|
||||
- **Effort:** S.
|
||||
|
||||
### F15. New Yacht dialog only lists clients in owner picker (task #44)
|
||||
|
||||
- **Impact:** Data model supports `'client' | 'company'` ownership; UI only lets you pick clients. Cannot create company-owned yacht via UI.
|
||||
- **Files:** `src/components/yachts/new-yacht-dialog.tsx` — add owner-type segmented control (Client / Company) above the owner picker; switch data source.
|
||||
- **Effort:** S.
|
||||
|
||||
### F16. InlineTagEditor "Add tag" focus + create flow (task #45)
|
||||
|
||||
- **Impact:** Typing in the tag widget set the CONTACT LABEL instead. Plus no "Create new tag" affordance for new tag names.
|
||||
- **Files:** `src/components/shared/inline-tag-editor.tsx`. Fix focus target; surface "Create new: X" as a popover item; orchestrate POST /api/v1/tags then PUT .../tags.
|
||||
- **Effort:** S.
|
||||
|
||||
### F17. Cross-port (and 404) detail URLs silently render list shell (task #48)
|
||||
|
||||
- **Impact:** User pastes a wrong-port URL → API 404s correctly but UI silently shows the list shell. No explicit "not found" message.
|
||||
- **Files:** every entity-detail client component — render `<EmptyState title="Not found" />` when GET returns 404. Apply to clients, interests, yachts, companies, berths.
|
||||
- **Effort:** M (apply pattern to each detail page).
|
||||
|
||||
### F18. Recommender `limit` param ignored (task #69)
|
||||
|
||||
- **Impact:** Request with `{"limit": 3}` returned 8 berths. Either param name mismatch or no clamp.
|
||||
- **Files:** `src/lib/services/berth-recommender.service.ts` + the recommend-berths validator.
|
||||
- **Effort:** XS.
|
||||
|
||||
---
|
||||
|
||||
## TIER 4 — Polish & UX Reductions (P4)
|
||||
|
||||
The `UX EFFICIENCY` list (task #24). Each is small, mostly copy/flow improvements.
|
||||
|
||||
### F19. New Client form — primary contact default trap
|
||||
|
||||
- Default-checked "Primary contact" with empty email silently rejects on submit. Either don't pre-add OR drop empty contacts on save.
|
||||
|
||||
### F20. New Interest dialog — redirect to detail page on create
|
||||
|
||||
- Currently returns to the list. Add `router.push('/interests/' + newId)` to land on the workflow page immediately.
|
||||
|
||||
### F21. Stage-transition error toast leaks developer language
|
||||
|
||||
- "yachtId is required before leaving stage=enquiry" → "Yacht is required before leaving the Enquiry stage."
|
||||
- Audit ALL ValidationError + ConflictError + service error messages for user-readable copy.
|
||||
|
||||
### F22. Stage menu uses unicode emoji `⚑` as prereq-blocked indicator
|
||||
|
||||
- Per user preference (memory: avoid decorative emoji), replace with a Lucide icon (`Lock`, `AlertCircle`, or `FlagOff`).
|
||||
|
||||
### F23. Blocked-stage UX — show prereq picker inline
|
||||
|
||||
- Clicking a blocked stage currently dismisses with a toast. Better: open the prereq picker inline ("Pick a yacht to leave Enquiry" with combobox right there).
|
||||
|
||||
### F24. New Client form — "Country" optional but prominent
|
||||
|
||||
- Drop from quick-path OR move to a "More details" disclosure.
|
||||
|
||||
### F25. Documents Hub — folder navigation doesn't update URL
|
||||
|
||||
- Drilling into a folder updates "Current location" but doesn't change `location.search`. Can't deep-link, browser-back broken, refresh resets to root.
|
||||
|
||||
### F26. "Reopen" outcome action silent — no toast
|
||||
|
||||
- After clicking Reopen, no feedback. Add `toast.success('Outcome cleared')` or similar.
|
||||
|
||||
### F27. Same-stage write returns full body — should be 204
|
||||
|
||||
- PATCH /stage with same stage = current stage returns 200 + full interest. Should be 204 No Content (no-op).
|
||||
|
||||
### F28. Recommender empty-result UI
|
||||
|
||||
- 300ft yacht returns `data: []` — UI Recommendations tab silently shows blank. Should render "No berths match — try relaxing constraints."
|
||||
|
||||
### F29. Inbox first-load "Loading..." stuck
|
||||
|
||||
- First navigation to /inbox shows "Loading..." indefinitely; subsequent reload renders fine. TanStack Query cache initialization issue.
|
||||
|
||||
### F30. Berths in default queries should filter `archivedAt IS NULL`
|
||||
|
||||
- Companion to F5 — once soft-delete lands, every default list query must filter archived rows.
|
||||
|
||||
---
|
||||
|
||||
## NEW FEATURE — Manual Berth Status Catch-Up Workflow (task #67)
|
||||
|
||||
User-requested. Foundation already exists (column `berths.status_override_mode` is in schema but never written).
|
||||
|
||||
### Phase 1 — Wire the status_override_mode field
|
||||
|
||||
- `updateBerthStatus()` sets `status_override_mode = 'manual'` when called via the user-facing API.
|
||||
- `berth-rules-engine.ts` triggers set `status_override_mode = 'automated'`.
|
||||
- When a backing interest is successfully created and links the berth, clear `status_override_mode` back to null in the same transaction; set `status_last_changed_reason` to "Reconciled via interest [id]".
|
||||
- **Effort:** S.
|
||||
|
||||
### Phase 2 — Visual indicator
|
||||
|
||||
- On berth list rows: small chip "Manual" next to the status badge when `status_override_mode = 'manual'` AND no active interest is linked.
|
||||
- On berth detail page header: badge + tooltip showing last reason, user, when.
|
||||
- On dashboard "Berth Heat" widget: filter or annotate the manual rows.
|
||||
- **Effort:** S.
|
||||
|
||||
### Phase 3 — Reconciliation Queue page
|
||||
|
||||
- New page `/[portSlug]/admin/berths/reconcile`.
|
||||
- Lists every berth where `status_override_mode = 'manual'` and no active interest. Sortable by `status_last_modified DESC`.
|
||||
- Each row links to the catch-up wizard.
|
||||
- Sidebar Admin section gets a link with the queue count badge.
|
||||
- **Effort:** S.
|
||||
|
||||
### Phase 4 — Catch-Up Wizard (the core piece)
|
||||
|
||||
- Multi-step modal. Steps:
|
||||
1. **Pick or create client** — combobox + inline quick-create (name + email only).
|
||||
2. **Pick or create yacht** — optional if pre-EOI; quick-create with name + dimensions.
|
||||
3. **Pick the matching stage** — based on current berth status:
|
||||
- `under_offer` → enquiry / qualified / nurturing / eoi (default eoi)
|
||||
- `sold` → contract + outcome=won
|
||||
- Allow override.
|
||||
4. **Upload existing docs** — EOI PDF, contract PDF, reservation form. Each auto-filed to the right entity folder.
|
||||
5. **Optional payments** — if status=sold, prompt for deposit/full amount.
|
||||
6. **Review + submit.** On submit, transaction:
|
||||
- Create/select client + yacht
|
||||
- Create interest at chosen stage with `assigned_to = current user`
|
||||
- Upsert `interest_berths(is_primary=true, is_specific_interest=true, is_in_eoi_bundle=true)`
|
||||
- Upload + attach files
|
||||
- Insert payments
|
||||
- Set `berth.status_override_mode = null` + `status_last_changed_reason = 'Reconciled via interest [id]'`
|
||||
- Audit log single "reconcile" event linking berth + new interest.
|
||||
- **Effort:** M (wizard) + S (transaction service) + S (API endpoint). Total M-L.
|
||||
|
||||
### Phase 5 — Entry points
|
||||
|
||||
- Berth list row menu → "Catch up..."
|
||||
- Berth detail page next to manual badge → "Catch up"
|
||||
- Dashboard widget "Manual statuses awaiting reconciliation" (count + link)
|
||||
- Sidebar link
|
||||
- **Effort:** S.
|
||||
|
||||
### Total feature effort: M-L (2-3 dev days).
|
||||
|
||||
---
|
||||
|
||||
## What I Tested in Round 2 (15 deep journeys, all passed structural validation)
|
||||
|
||||
| Journey | Result |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| State machine — stage skipping | ✓ Rejects forward/backward jumps with friendly copy + override path |
|
||||
| Double outcome write | ⚠ Allowed (won→lost flips freely); audit log just says "update" — should tag outcome change |
|
||||
| Cascade — delete with dependents | ✗ Inconsistent: clients soft-archive, **berths HARD-delete**, companies soft-archive |
|
||||
| Manual berth status without backing interest | ✗ Foundation column exists, never written |
|
||||
| Unicode (emoji/RTL/zero-width) | ⚠ Emoji + RTL OK; zero-width chars NOT stripped (search blind spot) |
|
||||
| Storage / file upload magic-byte | ✓ Rejects JPEG/HTML disguised as PDF |
|
||||
| Documenso webhook idempotency | ✓ Timing-safe + rate-limited bad-secret check |
|
||||
| Berth recommender edge cases | ⚠ Empty dims OK; extreme dims return empty; **limit param ignored** |
|
||||
| Email body XSS via markdown | ✓ Escape-first-then-rules, javascript: URLs stripped |
|
||||
| Public berth feed correctness | ✓ Port allow-list, archive filter, status enum validation |
|
||||
| Rate limiting / abuse | ✗ Login: no rate limit; public feed: CDN-cached |
|
||||
| Health check + dependency probes | ✓ Anonymous minimal payload, secret-mode for website-intake |
|
||||
| Direct ID enumeration | ✓ Uniform 404 — no leak |
|
||||
| Cross-port API access | ✓ 404 at API; **silent at UI** |
|
||||
| CSRF — fake Origin | ✓ Prod-only protection — dev intentionally skips |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Commit Sequence
|
||||
|
||||
1. **Squash-commit T0 fixes** (F1 + F2) — these are deploy-blockers already applied. Push to main.
|
||||
2. **T1 batch commit** (F3, F4, F5, F6, F7, F8) — pre-deploy blockers. Single commit per fix for clean review.
|
||||
3. **T2** (F9, F10) — schedule for next sprint (F9 is architectural).
|
||||
4. **T3** (F11-F18) — knock out in a few hours. Quick polish wave.
|
||||
5. **T4** (F19-F30) — UX list. Bundle into a single PR over a few sessions.
|
||||
6. **NEW FEATURE — Catch-Up Workflow** — 2-3 dev days. Higher business value than T2; prioritize after T1.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notes
|
||||
|
||||
- The audit polluted the dev DB with test entities: `Smoke Test Client (renamed)`, `Aurora Marine Holdings Ltd`, `Bad Email Test`, `Phone Test`, `Robert'; DROP TABLE clients`, `François 🏄 المعتمد`, `محمد عبد الله`, `CSRF Test`, etc. Also **hard-deleted berth A1 in port-amador** + soft-archived Test Person 1. Consider `pnpm db:reseed:synthetic` before the next clean run.
|
||||
- The Smoke Test Client interest had `outcome=lost_other` set during the won-then-lost test (R2-B). Audit log preserved both transitions but with action="update" not action="outcome_change".
|
||||
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._
|
||||
196
docs/admin-ux-backlog.md
Normal file
196
docs/admin-ux-backlog.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Admin / settings UX backlog — STATUS
|
||||
|
||||
Living tracker for the admin/UX backlog. Items are marked DONE or
|
||||
REMAINING based on what landed in the autonomous-push session.
|
||||
|
||||
---
|
||||
|
||||
## DONE in the autonomous push
|
||||
|
||||
### Foundations
|
||||
|
||||
- **Currency API verified end-to-end**. `scripts/test-currency-api.ts`
|
||||
fetches live Frankfurter rates → upserts → reads back → converts.
|
||||
Inverse-rate drift confirmed at ≤0.001.
|
||||
- **Storage abstraction audit complete**. Every byte path
|
||||
(signed EOIs, contracts, brochures, berth PDFs, files, avatars,
|
||||
branding logos) goes through `getStorageBackend()`. `/api/ready`
|
||||
and the system-monitoring health probe now check the active
|
||||
backend (S3 or filesystem) instead of always probing MinIO.
|
||||
|
||||
### User settings
|
||||
|
||||
- Country + Timezone selectors with cross-defaulting + auto-detect
|
||||
banner ("Looks like you're in Europe/Paris — Update?")
|
||||
- Email change with verification flow (`user_email_changes` table,
|
||||
`/api/v1/me/email/confirm/<token>`, `/api/v1/me/email/cancel/<token>`)
|
||||
- Password reset triggered via better-auth `requestPasswordReset`
|
||||
- Profile photo upload + crop (square 256×256) via shared
|
||||
`<ImageCropperDialog>` + `/api/v1/me/avatar`
|
||||
|
||||
### Branding
|
||||
|
||||
- Logo upload + crop modal in admin/branding (uses the same shared
|
||||
cropper, persists via `/api/v1/admin/settings/image` → storage backend)
|
||||
- Email header/footer HTML defaults injectable via "Insert default" button
|
||||
- Brand colour picker, app-name field, logo URL all in one card
|
||||
|
||||
### Storage admin
|
||||
|
||||
- New layout: S3 config form FIRST, swap action SECOND
|
||||
- Test connection button before any switch
|
||||
- Two-button switch: "Switch + migrate" vs "Switch only" with warning modal
|
||||
- `runMigration()` honours `skipMigration` flag
|
||||
|
||||
### Backup management
|
||||
|
||||
- Real `/admin/backup` page driven by new `backup_jobs` table
|
||||
- `runBackup()` service spawns `pg_dump --format=custom`, streams to
|
||||
active storage backend, records size + path
|
||||
- Download button presigns the .dump for offline restore
|
||||
- Super-admin gated
|
||||
|
||||
### AI admin panel
|
||||
|
||||
- Dedicated `/admin/ai` page consolidating master switch +
|
||||
monthly token cap + provider credentials
|
||||
- Per-feature settings (OCR, berth-PDF parser, recommender)
|
||||
linked from the same page
|
||||
|
||||
### Onboarding
|
||||
|
||||
- Real `/admin/onboarding` page with auto-checked steps
|
||||
- Reads each setting key + lists endpoint (roles / users / tags) to
|
||||
decide completion
|
||||
- Manual checkboxes for steps without an auto-detect signal
|
||||
- Progress bar + "Mark done"/"Mark incomplete" buttons
|
||||
- State persisted in `system_settings.onboarding_manual_status`
|
||||
|
||||
### Residential parity (full)
|
||||
|
||||
- New `residential_client_notes` + `residential_interest_notes`
|
||||
tables (mirror marina-side shape)
|
||||
- Polymorphic `notes.service.ts` extended with two new entity types
|
||||
through verifyParent + listForEntity + create + update + delete
|
||||
- New `<NotesList>` accepts `residential_clients` /
|
||||
`residential_interests` entity types
|
||||
- Activity endpoints: `/api/v1/residential/clients/[id]/activity` +
|
||||
`/api/v1/residential/interests/[id]/activity`
|
||||
- Notes endpoints: 4 new routes covering GET/POST/PATCH/DELETE
|
||||
- `residential-client-tabs.tsx` + `residential-interest-tabs.tsx`
|
||||
built using the marina-side `DetailLayout` pattern (Overview +
|
||||
Notes + Activity tabs, Interests tab on the client)
|
||||
- Detail header components mirror the marina-side strip
|
||||
- `useBreadcrumbHint` wired into both detail components
|
||||
|
||||
### Residential pipeline stages — configurable
|
||||
|
||||
- New `residential-stages.service.ts` with list/save + orphan-check
|
||||
- `/api/v1/residential/stages` GET/PUT
|
||||
- `/admin/residential-stages` admin UI with reassign-on-remove
|
||||
modal (select new stage per affected interest before save)
|
||||
- Validators relaxed from `z.enum(...)` to `z.string()` so any
|
||||
admin-defined stage id round-trips
|
||||
|
||||
### Documenso Phase 1 (EOI generate flow polish)
|
||||
|
||||
- Schema migrations applied:
|
||||
`document_signers.invited_at / opened_at / last_reminder_sent_at / signing_token`,
|
||||
`documents.completion_cc_emails / auto_reminder_interval_days`
|
||||
- `transformSigningUrl()` now maps SignerRole → URL segment correctly
|
||||
(approver→cc, witness→witness) so emails don't land on `/sign/error`
|
||||
- New `POST /api/v1/documents/[id]/send-invitation` endpoint with
|
||||
next-pending-signer auto-pick
|
||||
- Per-port settings added: `documenso_developer_label`,
|
||||
`documenso_approver_label`, `documenso_developer_user_id`,
|
||||
`documenso_approver_user_id` (Phase 7 RBAC binding fields)
|
||||
|
||||
### Misc UI/UX
|
||||
|
||||
- Sidebar collapse removed (always expanded)
|
||||
- Audit log filter inputs sized + dates widened
|
||||
- Custom Settings section got a long-form description
|
||||
- Reminder digest timezone uses `TimezoneCombobox`
|
||||
- Port form: currency dropdown + timezone combobox + brand color
|
||||
- Permissions count badge opens a modal with granted/denied
|
||||
- Role names display-normalized via `prettifyRoleName`
|
||||
- Sales email config: token list + tooltips on threshold + body fields
|
||||
- Custom Fields page: amber heads-up about non-integration with
|
||||
search / recommender / audit / merge tokens
|
||||
- Tag form: native `<input type="color">`
|
||||
- FilterBar Select crash fixed (no empty-string item values)
|
||||
|
||||
---
|
||||
|
||||
## REMAINING — large pieces that didn't fit this push
|
||||
|
||||
### 1. Documenso Phase 2 — Webhook handler enhancement (~3-4 hours)
|
||||
|
||||
Cascading "your turn" emails when each signer completes; on-completion
|
||||
PDF distribution; token-based recipient matching; idempotency lock.
|
||||
File to extend: `src/app/api/webhooks/documenso/route.ts`. The
|
||||
schema columns are already in place (Phase 1).
|
||||
|
||||
### 2. Documenso Phase 3 — Custom doc upload-to-Documenso (~6-8 hours)
|
||||
|
||||
Backend service `custom-document-upload.service.ts` + endpoint
|
||||
`POST /api/v1/interests/[id]/upload-for-signing`. Accepts a PDF +
|
||||
recipient list + field-placement JSON, calls `createDocument` →
|
||||
`placeFields` → `sendDocument` on the per-port Documenso client.
|
||||
Persists a row in `documents` table.
|
||||
|
||||
### 3. Documenso Phase 4 — Field placement UI (~10-14 hours)
|
||||
|
||||
The biggest piece. Needs:
|
||||
|
||||
- 4a: Recipient configurator dialog (~2-3h)
|
||||
- 4b: PDF rendering with `react-pdf` (~3-4h)
|
||||
- 4c: Auto-detect anchor scanner via `pdfjs-dist.getTextContent` (~4-6h)
|
||||
- 4d: Drag-drop overlay using `dnd-kit` (~3-4h)
|
||||
- 4e: Send button → calls Phase 3 endpoint (~1h)
|
||||
|
||||
Plan locked in `docs/documenso-build-plan.md` Phase 4 — the
|
||||
field-detector regexes, the anchor patterns, and the type-to-bbox
|
||||
sizing table are all spelled out.
|
||||
|
||||
### 4. Documenso Phase 5 — Embedded signing URL emission verification (~1-2 hours)
|
||||
|
||||
Verify the website's `/sign/<type>/<token>` page handles every signer
|
||||
role + every documentType combination. Update website's
|
||||
`signerMessages` map keyed on `(documentType, role)`. Apply the
|
||||
nginx CORS block from `docs/documenso-integration-audit.md`.
|
||||
|
||||
### 5. Documenso Phase 6 — Polish items (deferred)
|
||||
|
||||
Auto-send delay, audit-log additions, per-document customisation,
|
||||
document expiration, reminder rate-limit display, failed-webhook
|
||||
recovery UI. Each ~2-3 hours; all deferred until Phases 1-4 ship.
|
||||
|
||||
### 6. Project Director — UI binding for the developer-user fields
|
||||
|
||||
Schema + setting keys are now in place
|
||||
(`documenso_developer_user_id`, `documenso_approver_user_id` +
|
||||
`documenso_developer_label` / `_approver_label`). The remaining
|
||||
work is: add a "Linked to CRM user" dropdown in
|
||||
`/admin/documenso/page.tsx` that lists port users; when bound,
|
||||
auto-fill name/email from the user profile and mark name/email
|
||||
fields read-only. Webhook handler can then match against the
|
||||
linked user's email for in-CRM signing-status updates.
|
||||
|
||||
### 7. Custom-fields hardening (~ongoing)
|
||||
|
||||
Remediation paths for the heads-up banner concerns:
|
||||
|
||||
- **Search index**: extend the GIN tsvector to include
|
||||
customFieldValues content
|
||||
- **Audit diff**: extend `diffEntity` to walk the
|
||||
customFieldValues blob
|
||||
- **Merge tokens**: add `{{custom.<fieldName>}}` handling at
|
||||
template-render time, plus surface them in the merge-tokens UI
|
||||
|
||||
### 8. Documenso v2 webhook payload audit (small)
|
||||
|
||||
Risk #4 from `docs/documenso-build-plan.md` — confirm v2 payload
|
||||
shape (`payload.documentId` vs `payload.id`, recipient.token vs
|
||||
`recipient.recipientId`) against a live v2 instance before relying
|
||||
on Phase 2 cascading emails.
|
||||
117
docs/audit-2026-05-15.md
Normal file
117
docs/audit-2026-05-15.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Comprehensive Playwright Audit — 2026-05-15
|
||||
|
||||
Scope: full coverage of admin, sales-rep, viewer, portal, catch-up wizard, single-tree responsive shell, plus spot-checks on yacht / interest / berth detail surfaces.
|
||||
|
||||
## Setup
|
||||
|
||||
- Dev server: localhost:3000 (running)
|
||||
- Users:
|
||||
- super_admin: `admin@portnimara.test` / `SuperAdmin12345!`
|
||||
- sales_agent: `agent@portnimara.test` / `SalesAgent12345!`
|
||||
- viewer: `viewer@portnimara.test` / `ViewerUser12345!`
|
||||
- Port slug: `port-nimara`
|
||||
|
||||
## Verified working (positive findings)
|
||||
|
||||
- ✅ super-admin login + dashboard renders, all 34 admin pages return 200
|
||||
- ✅ Recent commits' workflow features:
|
||||
- F22 AlertTriangle icon on override-required stages
|
||||
- F23 inline yacht-prereq picker fires when leaving Enquiry without a yacht (confirmed end-to-end: "A yacht must be linked before leaving Enquiry. Pick one below to move to Qualified.")
|
||||
- F25 documents-hub folder selection persists in `?folder=root` querystring
|
||||
- F44 OwnerPicker has Client/Company tabs visible in popover (just hidden by Select trigger summary)
|
||||
- ✅ **#67 catch-up workflow end-to-end**: manually flipped berth A2 → reconciliation queue picked it up → wizard quick-created client + interest + cleared override + reason stamped "Reconciled via interest <id>" + redirected to interest detail
|
||||
- ✅ **#26 single-tree shell**: at viewport 390px only mobile shell mounts (1 nav, no desktop sidebar); at 1440px only desktop shell mounts; clean swap on resize
|
||||
- ✅ Permission gating: viewer + sales-agent get no "New Client"/admin nav; viewer POST to /clients returns 403
|
||||
- ✅ Audit log captures all writes (tag create, berth update, interest create, client create) including the reconcile event with `reconciledInterestId` metadata
|
||||
|
||||
## Findings
|
||||
|
||||
### A1 — Dashboard Recent Activity surfaces raw `permission_denied` rows with no label
|
||||
|
||||
- `/api/v1/dashboard/activity` returns entries with `action: "permission_denied"` and `label: null`. The activity feed renders just the action badge with nothing beside it. From earlier audits, 6 of these are stacked at the top of the dashboard for the super-admin.
|
||||
- Fix options: filter `permission_denied` out of the feed, OR map them to readable copy ("Permission denied: tried to view audit log (denied)") using `metadata.attemptedAction`.
|
||||
- Effort: XS.
|
||||
|
||||
### A2 — Activity feed renders legacy 9-stage enum values
|
||||
|
||||
- `pipelineStage: "deposit_10pct"` and `"contract_sent"` still appear in `oldValue` / `newValue` for historical rows. These should map to the 7-stage labels at render time so the feed reads as `Eoi → Deposit Paid` not `eoi_signed → deposit_10pct`.
|
||||
- The mapping table lives in seed-synthetic-data.ts (`details_sent→enquiry` etc.) — pull it into a shared `LEGACY_STAGE_REMAP` helper for activity-feed read paths.
|
||||
- Effort: S.
|
||||
|
||||
### A16 — File upload to documents hub root fails with validation error
|
||||
|
||||
- Repro: open `/documents`, click "Upload file", drop any file in. POST to `/api/v1/files/upload` returns 400 with field errors on `clientId`, `yachtId`, `companyId`, `category`, `entityType`, `entityId` — all "expected string, received null".
|
||||
- Root cause: the client sends `null` for unset optional fields; the validator expects them either absent or strings. Mismatch.
|
||||
- Fix: either make the zod schema accept `.nullable()` on those fields OR strip nulls in `FileUploadZone` / `FolderDropZone` before POST.
|
||||
- Effort: XS.
|
||||
|
||||
### A17 — `/api/v1/admin/ports` requires X-Port-Id but is the bootstrap port-resolver
|
||||
|
||||
- Symptom: as sales-agent, every page load fires a 400 to `/api/v1/admin/ports` ("Port context required"). Repeats on every apiFetch call because `apiFetch` calls this endpoint to resolve port-slug→port-id.
|
||||
- Bigger problem: the endpoint is gated to super-admin (`requireSuperAdmin`). Sales-reps and viewers will NEVER get a ports list from this endpoint, so the bootstrap path always falls through to the Zustand store. The 400 noise is wasted work + log spam.
|
||||
- Fix: add a `/api/v1/me/ports` endpoint that returns the caller's accessible ports without the super-admin gate, and have `client.ts` use it. OR seed the PortProvider context into a `__INITIAL_PORTS__` window global on first paint and skip the fetch entirely.
|
||||
- Effort: S.
|
||||
|
||||
### A18 — `/api/v1/users` returns 404 vs `/api/v1/admin/audit` returns 403 (inconsistent perm denials)
|
||||
|
||||
- Both endpoints reject sales-agent access but use different status codes. Pick one — either always 404 (hide existence) or always 403 (acknowledge but deny). The 403/404 split is the kind of inconsistency a pentester probes to map permissions.
|
||||
- Effort: XS sweep.
|
||||
|
||||
### A4 — F19 empty-contact filter never runs because zod-validation rejects first
|
||||
|
||||
- Repro: open New Client dialog, fill Full Name + one valid email, click "Add Contact" to insert an empty row, click Create Client. Nothing happens (no toast, no submit, no POST in network).
|
||||
- Root cause: my F19 fix put the empty-row prune in the **mutationFn**, but `handleSubmit(zodResolver)` validates the form FIRST. The empty contact's `value: z.string().min(1)` fails silently — handleSubmit short-circuits without surfacing an error on the empty row (the field has no `errors.contacts[1].value` rendered because the schema-level message attaches to the array path).
|
||||
- Fix: prune empty contact rows in a custom onSubmit wrapper BEFORE handleSubmit/zod sees them, OR change the field-array schema to allow empty rows and let the mutationFn prune.
|
||||
- Effort: XS.
|
||||
|
||||
### A19_b — Portal `/portal/login` shows "Client portal unavailable"
|
||||
|
||||
- The portal is gated by a per-port `client_portal_enabled` system setting. The route layout renders a friendly message but no admin path is obvious to a fresh-eyes operator.
|
||||
- Two distinct problems:
|
||||
- **Discoverability**: the admin landing card for "System Settings" doesn't surface a "Enable client portal" toggle prominently. A new operator would have to know the setting key.
|
||||
- **Portal scope**: the portal currently only has activation + reset password + sign-in surfaces. Once the rep logs the client in, they land on... what? Worth a separate scoping session to flesh out: their interests, their documents, their signing queue, payment history, message thread.
|
||||
- Recommendation: spec a "Phase 0 portal MVP" (read-only views of own interests + documents + signed-PDF download) before promoting it to clients. Treat the rest as v1.3 backlog.
|
||||
- Effort: portal MVP S-M depending on scope.
|
||||
|
||||
### A3 — Dev-only CSP error spam from react-grab
|
||||
|
||||
- `react-grab` dev script tries to load `fonts.googleapis.com/css2?family=Geist` and triggers a CSP block on every page load (2 console errors). Cosmetic since react-grab isn't loaded in prod, but the dev console gets noisy.
|
||||
- Fix: either drop the react-grab include or extend dev CSP `style-src` to allow `https://fonts.googleapis.com`.
|
||||
- Effort: XS.
|
||||
|
||||
### A5 — Socket.IO WebSocket repeatedly fails to connect in dev
|
||||
|
||||
- Console floods with "WebSocket is closed before the connection is established" — at least 6 occurrences per page in this session. Socket-io server endpoint at /socket.io/ isn't reachable from the Next dev server.
|
||||
- Likely root cause: Socket.IO server runs as a sidecar in compose but `pnpm dev` only starts Next, so the realtime channel is permanently broken in dev. Realtime invalidation features (interest/folder updates) silently never fire.
|
||||
- Fix: either start the socket server alongside `pnpm dev` (concurrently script), gate the SocketProvider behind a feature flag in dev, or stub the client to no-op when the endpoint 404s the first handshake.
|
||||
- Effort: S.
|
||||
|
||||
### A6 — Some DialogContent missing aria-describedby
|
||||
|
||||
- React warnings: `Missing 'Description' or 'aria-describedby={undefined}' for {DialogContent}`. At least one Dialog opens without a DialogDescription.
|
||||
- Fix: audit Dialog usages and either add a DialogDescription or pass `aria-describedby={undefined}` explicitly where genuinely no description is needed.
|
||||
- Effort: S.
|
||||
|
||||
### A8 — Legacy `statusOverrideMode = "auto"` values still in seed data
|
||||
|
||||
- Berth A1 (and likely others) has `statusOverrideMode: "auto"` from the NocoDB legacy import. The new code writes 'manual' | 'automated' | null; 'auto' is unrecognized.
|
||||
- Treated as "not manual" by the reconcile-queue filter so it's benign today, but the column should be normalized — either migrate legacy 'auto' → null in a migration, or treat 'auto' explicitly in the read paths.
|
||||
- Effort: XS.
|
||||
|
||||
### A9 — Catch-up wizard pipeline stage default doesn't match berth status
|
||||
|
||||
- Open the wizard on a berth where status=under_offer; the stage picker defaults to "New Enquiry" instead of "EOI" (the most common manual-flip case).
|
||||
- Root cause in `catch-up-wizard.tsx`: the default-stage logic only fires when the initial state isn't in the allowed set; 'enquiry' IS in the allowed set for under_offer, so it stays. Should default to EOI on first open via a `useEffect` keyed on `berth?.data.status`.
|
||||
- Effort: XS.
|
||||
|
||||
### A19 — F27 same-stage write still returns 200 + body instead of 204
|
||||
|
||||
- Spec said "same-stage write → 204 No Content (no-op)". The service early-returns `existing` correctly (no audit log emitted), but the route handler wraps it in `{ data: existing }` and returns 200.
|
||||
- Fix: have the service return a discriminated result like `{ kind: 'no-op' } | { kind: 'updated', interest }`, and the route handler returns 204 for the no-op branch.
|
||||
- Effort: XS (route handler tweak).
|
||||
|
||||
### A20 — F44 OwnerPicker — toggle hidden until popover opens (minor UX)
|
||||
|
||||
- The yacht-create form shows just "Select owner..." with no visible indication that it supports both clients AND companies. The Client/Company toggle pills only appear once the popover is open.
|
||||
- Fix option: surface "Owned by: Client | Company" as a segmented control above the picker, OR add a hint chip "Client/Company" next to the label.
|
||||
- Effort: XS.
|
||||
1126
docs/audit-comprehensive-2026-05-05.md
Normal file
1126
docs/audit-comprehensive-2026-05-05.md
Normal file
File diff suppressed because it is too large
Load Diff
753
docs/audit-comprehensive-2026-05-06.md
Normal file
753
docs/audit-comprehensive-2026-05-06.md
Normal file
@@ -0,0 +1,753 @@
|
||||
# Comprehensive Audit — 2026-05-06
|
||||
|
||||
Conducted directly after the smart-archive / hard-delete / bulk-wizard /
|
||||
audit-overhaul / synthetic-seed batches landed (commits `d07f1ed`
|
||||
through `9890d06`). Prior comprehensive audit:
|
||||
`docs/audit-comprehensive-2026-05-05.md`.
|
||||
|
||||
Findings are sorted by severity. Each has a concrete file:line, a
|
||||
scenario, and a fix recommendation.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### C1. 5 of 10 BullMQ workers are never imported (production + dev)
|
||||
|
||||
**Files:** `src/worker.ts:13-17`, `src/server.ts:72-76`
|
||||
|
||||
`src/worker.ts` (production) and `src/server.ts` (dev fallback) both
|
||||
import only:
|
||||
|
||||
- `emailWorker`
|
||||
- `documentsWorker`
|
||||
- `notificationsWorker`
|
||||
- `importWorker`
|
||||
- `exportWorker`
|
||||
|
||||
**Missing:** `aiWorker`, `bulkWorker`, `maintenanceWorker`, `reportsWorker`, `webhooksWorker`.
|
||||
|
||||
Because BullMQ workers are constructed at the top of each worker
|
||||
module and only "start" when the module is imported, never importing
|
||||
them means:
|
||||
|
||||
- **Webhooks never deliver.** `webhooksWorker` is what processes the
|
||||
`webhooks` queue; the admin "Replay" button we just shipped enqueues
|
||||
jobs that pile up in `pending` forever.
|
||||
- **All maintenance crons silently no-op.** `maintenanceWorker` handles
|
||||
`database-backup`, `backup-cleanup`, `session-cleanup`,
|
||||
`currency-refresh`, `gdpr-export-cleanup`, `ai-usage-retention`,
|
||||
`error-events-retention`, `website-submissions-retention`,
|
||||
`alerts-evaluate`, `analytics-refresh`, `calendar-sync`,
|
||||
`temp-file-cleanup`, `form-expiry-check` — none run.
|
||||
- **Scheduled reports never generate.** `reportsWorker` handles
|
||||
`report-scheduler` (every minute).
|
||||
- **Bulk jobs never process** (the synchronous bulk endpoints work, but
|
||||
any deferred-bulk path is dead).
|
||||
- **AI usage features never run.**
|
||||
|
||||
**Impact:** Production CRM has been silently shedding webhook
|
||||
deliveries, never running retention/cleanup, never sending scheduled
|
||||
reports.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```ts
|
||||
// Append to src/worker.ts AND the inline section of src/server.ts:
|
||||
import { aiWorker } from '@/lib/queue/workers/ai';
|
||||
import { bulkWorker } from '@/lib/queue/workers/bulk';
|
||||
import { maintenanceWorker } from '@/lib/queue/workers/maintenance';
|
||||
import { reportsWorker } from '@/lib/queue/workers/reports';
|
||||
import { webhooksWorker } from '@/lib/queue/workers/webhooks';
|
||||
|
||||
const workers = [
|
||||
emailWorker,
|
||||
documentsWorker,
|
||||
notificationsWorker,
|
||||
importWorker,
|
||||
exportWorker,
|
||||
aiWorker,
|
||||
bulkWorker,
|
||||
maintenanceWorker,
|
||||
reportsWorker,
|
||||
webhooksWorker,
|
||||
];
|
||||
```
|
||||
|
||||
After fix, run `pnpm dev` and watch `/admin/webhooks/{id}` deliveries
|
||||
go from `pending` → `success` to confirm.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1. Hard-delete request endpoints have zero rate limiting
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/api/v1/clients/[id]/hard-delete-request/route.ts:1-37`
|
||||
- `src/app/api/v1/clients/bulk-hard-delete-request/route.ts:1-32`
|
||||
|
||||
Each call writes a fresh code to Redis and emails it to the operator's
|
||||
address. No `withRateLimit(...)`. An attacker who has compromised an
|
||||
admin account (or even just the new `permanently_delete_clients`
|
||||
permission) can:
|
||||
|
||||
1. Email-bomb the admin's own inbox (every request → email).
|
||||
2. Probe whether arbitrary client IDs exist (200 + `sentToMaskedEmail`
|
||||
vs 404 `client not found` is a UID oracle).
|
||||
3. Burn SMTP quota.
|
||||
|
||||
**Fix:** add `withRateLimit('auth', ...)` or a new dedicated bucket
|
||||
(e.g. 5 per hour per user). Pattern is already in
|
||||
`src/app/api/v1/clients/[id]/gdpr-export/route.ts`.
|
||||
|
||||
### H2. Audit-page view fires on every paginated reload (log spam)
|
||||
|
||||
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
|
||||
|
||||
I added a "watch the watchers" `view` audit row for first-page audit
|
||||
fetches. That's the right idea, but the page also re-fires the request
|
||||
on every filter change (severity, source, action, date range, search).
|
||||
A diligent admin filtering through the inspector for an investigation
|
||||
will write dozens of `view` audit rows per minute — making it harder to
|
||||
find the actual events they're looking for.
|
||||
|
||||
**Fix:** dedupe in Redis with a 60-second per-user TTL key, only emit
|
||||
if the key didn't exist. Or only fire when no filters are active.
|
||||
|
||||
### H3. Hard-delete error messages distinguish "no code" vs "wrong code"
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:166-174`
|
||||
|
||||
```ts
|
||||
if (!stored) throw new ValidationError('Confirmation code expired or not requested');
|
||||
if (!safeEqualStr(stored, args.code.trim())) {
|
||||
throw new ValidationError('Confirmation code is incorrect');
|
||||
}
|
||||
```
|
||||
|
||||
The two messages let an attacker distinguish "you've never requested a
|
||||
code" (so spam the request endpoint to open the window) from "wrong
|
||||
code" (so brute-force more codes). 4-digit space is only 10,000 — with
|
||||
distinguishable feedback an attacker can confirm code validity in
|
||||
≤5,000 attempts on average.
|
||||
|
||||
**Fix:** collapse to a single `'Invalid or expired code'` message; the
|
||||
operator already has the email open and knows what they typed.
|
||||
|
||||
### H4. Synthetic seed leaves `super_admin` linked-port-roles empty
|
||||
|
||||
**File:** `src/lib/db/seed-bootstrap.ts:147-160`
|
||||
|
||||
The bootstrap creates the `userProfiles` row with
|
||||
`isSuperAdmin: true` for `super-admin-matt-portnimara`, but doesn't
|
||||
create `userPortRoles` rows. The actual real `user` rows (admin@,
|
||||
agent@, viewer@) are only created via the Playwright global-setup.
|
||||
Anyone running `pnpm db:seed:synthetic` then `pnpm dev` and trying to
|
||||
log in via the UI hits an unauthenticated state until they also run
|
||||
playwright setup or sign up via better-auth manually.
|
||||
|
||||
**Fix:** either document this in `CLAUDE.md` Quick Reference, or add a
|
||||
`pnpm db:seed:dev-users` companion script that signs up the three
|
||||
test users + links roles. Today's synthetic-seed flow felt clean
|
||||
because the playwright setup was still applied; in a fresh clone it
|
||||
will surprise.
|
||||
|
||||
### H5. Documenso bad-secret 200 response is correct, but enables enum oracle
|
||||
|
||||
**File:** `src/app/api/webhooks/documenso/route.ts:67-86`
|
||||
|
||||
The route returns `200 ok=false error=Invalid secret` for a wrong
|
||||
secret. That's webhook best-practice (don't leak signal to attackers),
|
||||
but combined with the new audit row that captures
|
||||
`metadata.providedLen`, an attacker can probe secret-length over time
|
||||
without being detected (just a "warning" row per attempt). On an admin
|
||||
inspector with 1000s of rows, a slow-rate probe is invisible.
|
||||
|
||||
**Fix:** add per-IP rate limit (5/min) to `/api/webhooks/documenso/`
|
||||
when secret check fails. Don't block real Documenso traffic — it
|
||||
shouldn't fail the secret check.
|
||||
|
||||
### H6. The audit-log inspector page itself isn't backed by a real "view" gate beyond `admin.view_audit_log`
|
||||
|
||||
**File:** `src/app/api/v1/admin/audit/route.ts:31`
|
||||
|
||||
Audit log has the most sensitive cross-cutting data in the system
|
||||
(every login attempt with attempted email, every secret-regenerate,
|
||||
every hard-delete). It's gated only by `admin.view_audit_log`. The
|
||||
seed grants this to `director` AND `super_admin`. Consider:
|
||||
|
||||
- making the page super-admin-only for production, OR
|
||||
- adding a secondary confirmation when viewing rows that contain
|
||||
attempted emails / IP ranges (PII).
|
||||
|
||||
**Fix:** change `withPermission('admin', 'view_audit_log', ...)` to
|
||||
add `if (!ctx.isSuperAdmin) check sensitive_audit_view`. Or accept
|
||||
the current model but document it in the role docs.
|
||||
|
||||
### H7. Three "coming soon" stubs in production UI
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/components/clients/client-tabs.tsx:276` — "File attachments coming soon."
|
||||
- `src/components/clients/client-reservations-tab.tsx:41` — "History is coming soon."
|
||||
- `src/components/berths/berth-tabs.tsx:327` — "{label} coming soon"
|
||||
|
||||
Visible to every user on every client / berth detail page. Either ship
|
||||
the feature or hide the tab.
|
||||
|
||||
**Fix:** for `client-tabs.tsx` line 276 (Files), the `files` table
|
||||
already exists and supports clientId — ship a list view.
|
||||
For `berth-tabs.tsx` line 327 — find the calling tab labels and
|
||||
either implement or remove from the tabs array.
|
||||
For `client-reservations-tab.tsx` line 41 — query past reservations
|
||||
when the user toggles a "show history" filter.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M1. `attachWorkerAudit` recurring job names list duplicates scheduler.ts (drift risk)
|
||||
|
||||
**File:** `src/lib/queue/audit-helpers.ts:23-46`
|
||||
|
||||
The 20 recurring job names are hardcoded in the audit helper; the
|
||||
scheduler also has its own list. If someone adds a new cron without
|
||||
updating both, the cron_run audit row never fires for that job.
|
||||
|
||||
**Fix:** export the list from `scheduler.ts` and import it in
|
||||
`audit-helpers.ts`. Single source of truth.
|
||||
|
||||
### M2. `client-merge-log.surviving_client_id` deleted by hard-delete (history loss)
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:200-202`
|
||||
|
||||
Hard-delete drops every `client_merge_log` row whose surviving id
|
||||
matches. Those rows are the audit trail of WHO was merged INTO this
|
||||
client. Once deleted, you've lost evidence of the prior merge.
|
||||
|
||||
**Fix:** replace `delete` with a column nullification, or move the row
|
||||
to a `client_merge_log_archive` table. Audit trail per GDPR Article 5
|
||||
should outlive the data.
|
||||
|
||||
### M3. Bulk hard-delete loops one-shot codes through Redis (5x writes)
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:382-396`
|
||||
|
||||
For a 100-client bulk delete, the function writes 100 single-client
|
||||
codes to Redis just to satisfy `hardDeleteClient`'s expectation. Each
|
||||
write is a round-trip; on a Redis hiccup mid-loop, you can end up
|
||||
with a half-deleted batch.
|
||||
|
||||
**Fix:** refactor `hardDeleteClient` so the inner deletion can be called
|
||||
without the per-client code check (extract `_doHardDelete()` private
|
||||
helper used by both single and bulk paths). Keeps Redis clean.
|
||||
|
||||
### M4. Smart-restore wizard has dead reversal applier for `berth_released`
|
||||
|
||||
**File:** `src/lib/services/client-restore.service.ts:360-372`
|
||||
|
||||
The `applyReversal` switch case for `'berth_released'` does nothing —
|
||||
it just leaves the berth available. The wizard surfaces this as
|
||||
"auto-reversible" if the berth is still free, but the actual restore
|
||||
doesn't re-attach the berth to any interest. Operator clicks Restore
|
||||
expecting their berth back; nothing changes on the berth.
|
||||
|
||||
**Fix:** either (a) at archive time, persist the original interestId
|
||||
in the decision metadata so we can re-link, or (b) update the wizard
|
||||
copy to make clear the berth is "available for re-attach" rather than
|
||||
"will be re-attached."
|
||||
|
||||
### M5. Several services use `void createAuditLog(...)` without `.catch()`
|
||||
|
||||
**Files:** widespread; e.g. `src/lib/services/client-hard-delete.service.ts:127-136, 230-240`,
|
||||
`src/lib/services/portal-auth.service.ts:269-276`
|
||||
|
||||
`createAuditLog` is documented as never-throwing (catches internally),
|
||||
but defense-in-depth: a `void` Promise that throws produces an
|
||||
unhandled rejection event. Most paths are fine because the helper
|
||||
catches; if anyone refactors `createAuditLog` and removes the catch,
|
||||
this becomes a process-killer.
|
||||
|
||||
**Fix:** convention rule: every `void someAsync()` must have a `.catch()`.
|
||||
Codify with a custom ESLint rule, or wrap at call sites:
|
||||
`void createAuditLog({...}).catch(() => undefined);`
|
||||
|
||||
### M6. Hard-delete audit metadata leaks client `fullName`
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:241-247`
|
||||
|
||||
After the hard-delete the audit row carries
|
||||
`metadata: { fullName: client.fullName }`. The client record itself is
|
||||
gone but their name lives on in the audit log. For a GDPR data subject
|
||||
who exercised their right-to-erasure, this is technically a retention
|
||||
of personal data in audit history. Not necessarily wrong (audit logs
|
||||
have a legitimate-interest basis), but should be conscious.
|
||||
|
||||
**Fix:** decide policy: either (a) keep as-is and document, (b) replace
|
||||
with a hash of the name, or (c) substitute a tombstone identifier.
|
||||
|
||||
### M7. Webhook delivery DLQ admin-replay can re-trigger downstream side-effects
|
||||
|
||||
**File:** `src/lib/services/webhooks.service.ts:282-326`
|
||||
|
||||
Replaying a successful webhook (operator presses Replay on a delivery
|
||||
that already had `status: 'success'`) re-fires the same payload to the
|
||||
recipient. If the recipient's idempotency check is weak, you've just
|
||||
caused a duplicate. The replay payload includes `retried_from` /
|
||||
`retried_at` markers, which is good — but most recipients won't honor
|
||||
them.
|
||||
|
||||
**Fix:** disable the Replay button when `status === 'success'`. The UI
|
||||
already gates on `'failed' || 'dead_letter'` — verify it stays that
|
||||
way (`webhook-delivery-log.tsx:118-131` looks correct; double-check
|
||||
no regressions).
|
||||
|
||||
### M8. `audit_logs` table has no DELETE permission gate
|
||||
|
||||
**Files:** schema and routes
|
||||
|
||||
There's no admin endpoint to delete audit rows (good). But there's no
|
||||
DB-level guard either. A super_admin who runs `db:reset` wipes audit
|
||||
history. Audit retention should be enforced at the schema level so
|
||||
even a misconfigured operator can't blow away the trail.
|
||||
|
||||
**Fix:** create a `audit_logs_no_delete_role` postgres role that lacks
|
||||
DELETE on the table; document that the app's DB user should not have
|
||||
DELETE on `audit_logs` in production deployments.
|
||||
|
||||
### M9. Documenso void worker uses dynamic import every time
|
||||
|
||||
**File:** `src/lib/queue/workers/documents.ts:25`
|
||||
|
||||
```ts
|
||||
const { voidDocument } = await import('@/lib/services/documenso-client');
|
||||
```
|
||||
|
||||
Dynamic import inside a hot per-job path is fine the first time but
|
||||
slows every subsequent call slightly. Move to top-of-file import
|
||||
unless there's a deliberate reason (circular dep?).
|
||||
|
||||
**Fix:** test moving to top-level import; if it works (no circular
|
||||
deps), keep it there.
|
||||
|
||||
### M10. Bulk archive wizard "blocked" reason copy truncates at first line
|
||||
|
||||
**File:** `src/components/clients/bulk-archive-wizard.tsx:153-163`
|
||||
|
||||
The wizard shows `b.blockers[0]` for blocked clients. If the dossier
|
||||
has multiple blockers, only the first is shown. Operators may fix the
|
||||
first one, retry, and discover a second.
|
||||
|
||||
**Fix:** show all blockers (joined with `·`) or a "+N more" badge
|
||||
with click-to-expand.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### L1. `next-in-line-notify.service.ts` could double-fire on archive retry
|
||||
|
||||
**File:** `src/app/api/v1/clients/[id]/archive/route.ts:114-135`
|
||||
|
||||
If the smart-archive request succeeds at the DB transaction level but
|
||||
the response upload-side fails (network blip, browser closes), the
|
||||
operator may retry. Each retry re-fires the next-in-line notification
|
||||
to all sales recipients. The `dedupeKey: berth-released:{berthId}`
|
||||
inside the notification helper deduplicates within a cooldown window —
|
||||
so this is mitigated, but worth verifying the cooldown is set and
|
||||
not 0.
|
||||
|
||||
### L2. `interests.berth_id` reference in `seed-data.ts` (legacy seed)
|
||||
|
||||
**File:** `src/lib/db/seed-data.ts:973`
|
||||
|
||||
The realistic seed inserts `berthId: ...` on the interests table. Per
|
||||
`CLAUDE.md`, that column was dropped in migration 0029 and replaced
|
||||
with `interest_berths` junction. The synthetic seed uses the junction
|
||||
correctly. The realistic seed will FAIL at insert time if anyone
|
||||
tries to run it on a freshly-migrated DB.
|
||||
|
||||
**Fix:** rewrite `seed-data.ts:969-982` to insert into `interests`
|
||||
without `berthId`, then insert the junction rows separately (mirror
|
||||
the synthetic seed's pattern).
|
||||
|
||||
### L3. Audit log entry for failed login uses `entityId = attemptedEmail` (unbounded)
|
||||
|
||||
**File:** `src/app/api/auth/[...all]/route.ts:53-68`
|
||||
|
||||
If the entityId is very long (a 500-char "email"), it goes into the
|
||||
DB column. The column is `text` (unbounded) so no DB error, but FTS
|
||||
search-text may bloat.
|
||||
|
||||
**Fix:** truncate attempted email to 256 chars before using as
|
||||
entityId.
|
||||
|
||||
### L4. The "watch the watchers" audit fires for filtered queries too
|
||||
|
||||
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
|
||||
|
||||
(See H2 above for the page-spam variant.) Even on a single search,
|
||||
an audit row containing the search term is written. If the search
|
||||
term itself is sensitive (e.g. an admin searches for a specific
|
||||
client's name in audit logs), it's now in the audit log of audit-log
|
||||
viewing. Acceptable but worth documenting.
|
||||
|
||||
### L5. Import worker is a stub
|
||||
|
||||
**File:** `src/lib/queue/workers/import.ts:13`
|
||||
|
||||
`// TODO(L2): implement import job handlers` — the worker is wired
|
||||
into the queue and registered, but does nothing. If anyone enqueues
|
||||
an `import:*` job, it returns immediately. Either ship the feature
|
||||
or remove the queue.
|
||||
|
||||
### L6. `interest-form.tsx` two TODOs about company-yacht filter + add-yacht inline
|
||||
|
||||
**File:** `src/components/interests/interest-form.tsx:332-333`
|
||||
|
||||
Real product gaps. When creating an interest for a client who's a
|
||||
member of a company, you can't pick a yacht owned by that company.
|
||||
And there's no inline "Add yacht" shortcut in the form.
|
||||
|
||||
### L7. `berth-spec-template.ts` defaults to `'Price: TBD'` when price is null
|
||||
|
||||
**File:** `src/lib/pdf/templates/berth-spec-template.ts:128`
|
||||
|
||||
Generated berth-spec PDFs say "Price: TBD" for any berth without a
|
||||
price. Cosmetic — verify whether sales considers this an acceptable
|
||||
fallback or wants to suppress the line entirely.
|
||||
|
||||
---
|
||||
|
||||
## Things checked and found OK (so we don't re-audit)
|
||||
|
||||
- Tenant isolation on hard-delete (`portId` filter on every query and
|
||||
inside the tx).
|
||||
- `withPermission` gates on every new route (bulk-archive-preflight,
|
||||
hard-delete-_, bulk-hard-delete-_, redeliver).
|
||||
- Audit log: no public DELETE endpoint, no PATCH endpoint.
|
||||
- Sidebar nav properly gates marina sections from `residential_partner`
|
||||
via `hasMarinaAccess`.
|
||||
- Auth wrapper rebuilds the request body correctly so the upstream
|
||||
better-auth handler can re-read it (no body-already-consumed bug).
|
||||
- Webhook outbound SSRF guard with DNS rebinding protection still
|
||||
intact.
|
||||
- 1175/1175 vitest suite passing as of last run.
|
||||
|
||||
---
|
||||
|
||||
## Recommended fix order (ROUND 1 + 2 combined — see below for Round 2)
|
||||
|
||||
See **"Triage list" at the end** of this document — combined ranking
|
||||
across both audit rounds.
|
||||
|
||||
---
|
||||
|
||||
## Round 2 — focused agents (added 2026-05-06 evening)
|
||||
|
||||
After the original synthesis above, four scoped agents (smaller blast
|
||||
radius, hard finding caps) successfully audited their domains and
|
||||
produced dedicated docs. Findings are linked here with `R2-`-prefixed
|
||||
IDs. Detail in:
|
||||
|
||||
- [audit-reliability-2026-05-06.md](audit-reliability-2026-05-06.md) — 11 findings
|
||||
- [audit-frontend-2026-05-06.md](audit-frontend-2026-05-06.md) — 12 findings
|
||||
- [audit-permissions-2026-05-06.md](audit-permissions-2026-05-06.md) — 9 findings
|
||||
- [audit-missing-features-2026-05-06.md](audit-missing-features-2026-05-06.md) — 12 findings
|
||||
|
||||
### Round 2 — CRITICAL
|
||||
|
||||
**R2-C1. Bulk archive discards post-commit side effects** ([reliability C1](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/app/api/v1/clients/bulk/route.ts:68-134`
|
||||
- The bulk wizard's `runBulk` callback discards the return value from
|
||||
`archiveClientWithDecisions`. **Documenso envelopes marked
|
||||
`void_documenso` are never queued for void; "next-in-line" sales
|
||||
notifications never fire**. The CRM ends up showing `documents.status='cancelled'`
|
||||
while the live envelope is still out for signature — a signer can
|
||||
legally complete a doc the CRM thinks is voided.
|
||||
- Same severity tier as the original C1 (worker-imports).
|
||||
|
||||
**R2-C2. Frontend: Restore icon hovers destructive-red on archived clients** ([frontend C1](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-detail-header.tsx:174-186`
|
||||
- Conditional `hover:text-destructive` is overridden by an unconditional
|
||||
`hover:text-foreground` earlier in the class string. Result: the
|
||||
Restore button on archived clients hovers blood-red, signalling
|
||||
"destructive" on a fully reversible action. Users hesitate to click.
|
||||
Promoted to "critical UX" because it's directly misleading on every
|
||||
archived client view.
|
||||
|
||||
### Round 2 — HIGH
|
||||
|
||||
**R2-H1. Smart-restore wizard's `berth_released` reversal is a no-op but the audit log claims success**
|
||||
([reliability H1](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/client-restore.service.ts:359-372`
|
||||
- Already noted as M4 in the original synthesis. Round-2 reliability
|
||||
agent escalated to HIGH because the wizard counter increments and
|
||||
the audit log records "1 auto-reversed" — operator believes the berth
|
||||
was re-attached when nothing happened. Same fix path: persist the
|
||||
original `interestId` in the decision detail and re-link on restore.
|
||||
|
||||
**R2-H2. Smart-archive berth status update has TOCTOU race**
|
||||
([reliability H2](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/client-archive.service.ts:191-207`
|
||||
- Berth row read outside tx, mutated inside tx without `for update`
|
||||
lock. Concurrent archive + sale of the same berth can race: the
|
||||
archive flow flips a freshly-sold berth back to `available`. Add
|
||||
`select … for update` on `berths` before the status flip.
|
||||
|
||||
**R2-H3. Bulk archive can pick the wrong interest for berth release**
|
||||
([reliability H3](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/app/api/v1/clients/bulk/route.ts:95-103`
|
||||
- Lookup by `primaryBerthMooring` falls back to `dossier.interests[0]?.interestId ?? ''`.
|
||||
Empty-string `interestId` reaches the delete and silently matches
|
||||
zero rows; the link is silently retained while the audit log claims
|
||||
it was removed.
|
||||
|
||||
**R2-H4. External EOI runs five operations outside a transaction**
|
||||
([reliability H4](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/external-eoi.service.ts:67-155`
|
||||
- Storage upload + 4 DB writes are independent. Mid-flight failure
|
||||
leaves orphan PDFs in S3/MinIO and partial DB state.
|
||||
|
||||
**R2-H5. Bulk wizard double-submit treats `ConflictError('already archived')` as a per-row error**
|
||||
([reliability H5](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/app/api/v1/clients/bulk/route.ts:68-120`
|
||||
- No idempotency key on the bulk endpoint. A double-submit (network
|
||||
retry, double click) makes the second response look like all rows
|
||||
failed even though the first succeeded.
|
||||
|
||||
**R2-H6. Webhook replay button has no UI permission gate (403 toast spam)**
|
||||
([permissions H1](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
|
||||
- Replay button renders for any user who can load the page. Server gates
|
||||
on `admin.manage_webhooks`. Non-admins see enabled buttons; clicking
|
||||
surfaces a generic 403 toast.
|
||||
|
||||
**R2-H7. Bulk Archive bulk action exposed to roles without `clients.delete`**
|
||||
([permissions H2](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-list.tsx:182-190`
|
||||
- `sales_agent` and `viewer` see the Archive bulk action; clicking
|
||||
surfaces a 403 from preflight. Mirror the `canHardDelete` pattern:
|
||||
`const canBulkArchive = can('clients', 'delete');`
|
||||
|
||||
**R2-H8. Bulk add_tag / remove_tag exposed to viewer**
|
||||
([permissions H3](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-list.tsx:165-181`
|
||||
- Same pattern as R2-H7 — no UI gate; server gates on `clients.edit`.
|
||||
|
||||
**R2-H9. Bulk hard-delete silently skips rows that vanish between preflight and execute**
|
||||
([permissions H4](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/client-hard-delete.service.ts:377`
|
||||
- `if (!c) continue;` swallows any client that was archived/restored/
|
||||
deleted by another operator between preflight and execute. Operator
|
||||
sees a `deletedCount` lower than requested and no signal which IDs
|
||||
were skipped.
|
||||
|
||||
**R2-H10. Frontend: `webhook-delivery-log` and `audit-log-list` swallow fetch errors silently**
|
||||
([frontend H3, H4](audit-frontend-2026-05-06.md))
|
||||
|
||||
- Files: `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`,
|
||||
`src/components/admin/audit/audit-log-list.tsx:150-175`
|
||||
- Both wrap fetches in `try/finally` with no `catch`. Failed loads show
|
||||
spinner forever or stale data; user has no signal that anything
|
||||
failed. Surface via `toast.error` + inline retry banner.
|
||||
|
||||
**R2-H11. Frontend: `audit-log-card` renders as `<a href="#">` — page-jumps on mobile tap**
|
||||
([frontend H5](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/admin/audit/audit-log-card.tsx:96`
|
||||
- Card view rows on mobile insert `#` in URL on tap (back-button trap).
|
||||
Render as button or div, or link to a useful destination.
|
||||
|
||||
**R2-H12. Frontend: `smart-archive-dialog` doesn't invalidate the dossier or single-client query**
|
||||
([frontend H6](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/smart-archive-dialog.tsx:197-212`
|
||||
- Detail page header keeps showing client as un-archived after a
|
||||
successful archive until hard reload. Add
|
||||
`qc.invalidateQueries({queryKey: ['clients', clientId]})` and
|
||||
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})`.
|
||||
|
||||
**R2-H13. Frontend: bulk tag mutation uses `alert()` and lacks `onError`**
|
||||
([frontend H2](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-list.tsx:88-106`
|
||||
- Native `alert()` blocks the page on partial failure; pure network
|
||||
failure shows nothing. Replace with `toast.warning` / `toast.error`.
|
||||
|
||||
**R2-H14. Email-template subject overrides are no-ops for 6 of 8 templates**
|
||||
([missing-features V1](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Files: `src/components/admin/email-templates-admin.tsx:24-72` (UI),
|
||||
`src/lib/services/portal-auth.service.ts:120,332` (only consumers)
|
||||
- Admin sees an "Overridden" badge after saving a custom subject for
|
||||
CRM invite, inquiry confirmation, residential templates, etc. — but
|
||||
the senders ship the hardcoded subject regardless. Wire
|
||||
`loadSubjectOverride(portId, key)` into the 6 missing senders.
|
||||
|
||||
**R2-H15. Branding admin saves 5 settings that nothing reads**
|
||||
([missing-features V2](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Files: `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx`,
|
||||
`src/lib/services/port-config.ts:240-272`
|
||||
- Logo URL, app name, primary color, header HTML, footer HTML all
|
||||
dead-end. `getPortBrandingConfig` has zero callers. **Multi-tenant
|
||||
promise broken — every port's emails ship Port Nimara's branding.**
|
||||
|
||||
**R2-H16. Reminder admin saves digest defaults that no scheduler applies**
|
||||
([missing-features V3](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Files: `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx`,
|
||||
`src/lib/services/port-config.ts:284-306`
|
||||
- Sales reps think they configured a daily digest at 09:00 in their
|
||||
TZ; they get fire-as-they-hit notifications instead. The digest
|
||||
scheduler doesn't exist.
|
||||
|
||||
### Round 2 — MEDIUM (selected highlights)
|
||||
|
||||
**R2-M1. Portal "My Memberships" tile is a dead-end** ([missing-features V4](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Tile on `/portal/dashboard` has no `href`; route doesn't exist. Either
|
||||
ship `/portal/memberships` or remove the tile.
|
||||
|
||||
**R2-M2. Company detail Documents tab is a "Coming soon" stub** ([missing-features V5](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- `src/components/companies/company-tabs.tsx:230-234`. Same problem
|
||||
as the three already-noted "coming soon" stubs but on a different
|
||||
entity.
|
||||
|
||||
**R2-M3. Onboarding page is a static checklist not the wizard it advertises** ([missing-features V6](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- The page literally says "what this page will become". Either build
|
||||
the wizard or relabel the landing card.
|
||||
|
||||
**R2-M4. Backup admin page is a docs page despite landing copy promising "on-demand exports"** ([missing-features V7](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Once C1 (worker imports) is fixed, the existing `database-backup`
|
||||
job is reachable; small lift to wire a "Take backup now" button.
|
||||
|
||||
**R2-M5. Inquiry inbox has zero triage actions** ([missing-features V8](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- No "Convert to client", no "Resolve", no "Assign". `website_submissions`
|
||||
table is permanent; sales has to copy-paste emails into client forms.
|
||||
|
||||
**R2-M6. external-eoi grants only `documents.upload_signed` but mutates interest state** ([permissions M1](audit-permissions-2026-05-06.md))
|
||||
|
||||
- A custom role with `documents.upload_signed:true` + `interests.edit:false`
|
||||
can flip an interest to "signed" via the external-EOI route.
|
||||
|
||||
**R2-M7. `InlineStagePicker` never sends `override:true` — `override_stage` permission unreachable from the most-used UI path** ([permissions M2](audit-permissions-2026-05-06.md))
|
||||
|
||||
- Users with the perm have to fall back to the modal `InterestStagePicker`
|
||||
to actually use it.
|
||||
|
||||
**R2-M8. `sales_agent` granted `interests.override_stage:true` — likely copy-paste from sales_manager** ([permissions M3](audit-permissions-2026-05-06.md))
|
||||
|
||||
- All other trust-elevated flags are stripped from sales_agent. Needs a
|
||||
product decision; either flip to false or document intent.
|
||||
|
||||
**R2-M9. `bulk-archive-preflight` leaks dossier-loader error text in `blockers`** ([permissions M4](audit-permissions-2026-05-06.md))
|
||||
|
||||
- An attacker enumerating UUIDs can distinguish "doesn't exist" vs
|
||||
"exists but you can't see it". Replace with generic "Could not load
|
||||
dossier".
|
||||
|
||||
**R2-M10. Documenso void worker has no max-retry alert hook** ([reliability M2](audit-reliability-2026-05-06.md))
|
||||
|
||||
- A persistent 401/403 retries forever. On exhaustion, write back to
|
||||
`documents` (`cancellation_failed=true`) and notify admin.
|
||||
|
||||
**R2-M11. Mobile More-sheet missing residential, notifications, berth-reservations, website-analytics** ([missing-features V9](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Mobile users have zero path to entire feature domains. Add to
|
||||
`MORE_ITEMS`.
|
||||
|
||||
**R2-M12. Portal has no profile / change-password surface** ([missing-features V10](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Forces every portal user to use the forgot-password flow even when
|
||||
they remember their old password. Ship `/portal/profile`.
|
||||
|
||||
**R2-M13. Portal invoices show amounts but no PDF download** ([missing-features V11](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Documents page does have downloads; mirror the pattern.
|
||||
|
||||
(Plus several more medium/low items in the dedicated docs; see those
|
||||
for the full set.)
|
||||
|
||||
---
|
||||
|
||||
## TRIAGE LIST (combined Round 1 + Round 2)
|
||||
|
||||
### Ship now — CRITICAL
|
||||
|
||||
1. **C1** — wire the 5 missing BullMQ workers (`worker.ts`, `server.ts`)
|
||||
— 5-line fix; every webhook + cron flow is currently dead.
|
||||
2. **R2-C1** — make bulk archive enqueue Documenso voids + next-in-line
|
||||
notifications (return value plumbing in `bulk/route.ts`).
|
||||
3. **R2-C2** — fix the destructive-red hover on the Restore button
|
||||
(`client-detail-header.tsx`). Trivial CSS fix.
|
||||
|
||||
### Ship this week — HIGH (security/UX with concrete user impact)
|
||||
|
||||
4. **H1** — rate-limit the hard-delete-request endpoints.
|
||||
5. **H3** — collapse "no code" vs "wrong code" into one error message.
|
||||
6. **H7** — three "coming soon" stubs in client/berth tabs.
|
||||
7. **R2-H1** — fix smart-restore's silent `berth_released` no-op (or
|
||||
reclassify as `reversibleWithPrompt`).
|
||||
8. **R2-H2** — add `for update` lock on the smart-archive berth status
|
||||
flip (TOCTOU race).
|
||||
9. **R2-H3** — bulk-archive's wrong-interest fallback — empty-string
|
||||
interestId silently no-ops.
|
||||
10. **R2-H6, R2-H7, R2-H8** — three permission UI-gate misses on
|
||||
bulk actions and the webhook-replay button. ~30 lines total.
|
||||
11. **R2-H10, R2-H12, R2-H13** — frontend swallowed errors + missing
|
||||
invalidation + alert() instead of toast. Small fixes, immediate UX
|
||||
win.
|
||||
12. **R2-H11** — `audit-log-card` `href="#"` mobile back-button trap.
|
||||
13. **R2-H14** — wire 6 missing email-subject overrides through their
|
||||
senders.
|
||||
|
||||
### Next sprint — HIGH/MEDIUM (operational + multi-tenant correctness)
|
||||
|
||||
14. **R2-H4** — wrap external-EOI in a transaction.
|
||||
15. **R2-H5** — bulk-archive idempotency key + treat already-archived as
|
||||
success in bulk.
|
||||
16. **R2-H9** — bulk hard-delete should return `skipped: string[]`.
|
||||
17. **R2-H15, R2-H16** — branding + reminder admin pages save settings
|
||||
nothing reads (silently broken multi-tenancy).
|
||||
18. **H2** — audit-page-view de-dupe (don't spam on every filter change).
|
||||
19. **H4** — synthetic seed needs documented dev-user setup or its own
|
||||
bootstrap script.
|
||||
20. **H5** — Documenso bad-secret rate-limit per IP.
|
||||
21. **R2-M1 through R2-M5** — portal memberships dead-end, company
|
||||
Documents stub, onboarding wizard, backup page, inquiry inbox triage.
|
||||
|
||||
### Backlog — MEDIUM/LOW + remaining items
|
||||
|
||||
22. The remaining MEDIUM/LOW from both rounds — see the dedicated docs.
|
||||
|
||||
---
|
||||
|
||||
## Headline numbers (combined)
|
||||
|
||||
- **3 CRITICAL** (worker imports, bulk-archive side-effects, restore-button hover)
|
||||
- **22 HIGH** (security + UX with concrete impact)
|
||||
- **~15 MEDIUM** (operational hygiene, multi-tenancy gaps, unfinished features)
|
||||
- **~10 LOW** (cleanup, defensive)
|
||||
|
||||
Round 1 was a manual synthesis after agent-pool stalls; Round 2 was
|
||||
four focused agents with hard finding caps that all completed inside
|
||||
the watchdog window. Every finding is grounded in code references.
|
||||
278
docs/audit-final-deferred.md
Normal file
278
docs/audit-final-deferred.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Final audit deferred findings
|
||||
|
||||
> **Status update (audit-v3 round)**: most of the v2 deferred items have
|
||||
> now landed. Items struck through below are completed. The remaining
|
||||
> open items are bigger refactors (custom-fields per-entity routes,
|
||||
> systemSettings PK reconciliation, Documenso v2 voidDocument verification,
|
||||
> partial-vs-composite archived index conversion, storage-proxy port_id
|
||||
> claim, Documenso webhook port_id enforcement, response-shape
|
||||
> standardization, berths.current_pdf_version_id Drizzle FK).
|
||||
|
||||
The pre-merge audit on `feat/berth-recommender` produced ~30 findings. The
|
||||
critical + high-severity items were fixed in-branch. The items below are
|
||||
medium / low severity and deferred to follow-up issues so the merge isn't
|
||||
held up. Each entry is self-contained — pick one off and ship it.
|
||||
|
||||
## Cross-cutting integration
|
||||
|
||||
- **EOI in-app pathway silently swallows missing `Berth Range` AcroForm field**
|
||||
— `src/lib/pdf/fill-eoi-form.ts:93`. `setText(form, 'Berth Range', ...)`
|
||||
is wrapped in a try/catch that succeeds silently when the field is
|
||||
absent. CLAUDE.md already warns ops about needing to add the field to
|
||||
the live Documenso template; this code change would make the deployment
|
||||
gap observable. Fix: when `context.eoiBerthRange` is non-empty AND the
|
||||
field is absent, log at warn level + surface a structured response field.
|
||||
|
||||
- **Email body merge expansion happens after token validation** —
|
||||
`src/lib/services/document-sends.service.ts:399-403`. If a merge value
|
||||
contains a `{{token}}` substring (e.g. a client name like
|
||||
`"Acme {{discount}} Inc."`), the expanded body will contain a token
|
||||
the unresolved-check missed and ships with literal braces. Fix: HTML-
|
||||
escape merge values before expansion, OR run a second
|
||||
`findUnresolvedTokens` against the expanded body.
|
||||
|
||||
- **Filesystem dev-fallback HMAC secret can drift across processes** —
|
||||
`src/lib/storage/filesystem.ts:328-331`. The dev-only fallback derives
|
||||
the HMAC secret from `BETTER_AUTH_SECRET`. Two CRM processes running
|
||||
with different secrets (web vs worker) reject each other's tokens.
|
||||
Fix: assert `BETTER_AUTH_SECRET` is set when filesystem backend is
|
||||
active in non-prod, or document the requirement loudly.
|
||||
|
||||
- **Berth PDF apply path: numeric column nulling silently drops** —
|
||||
`src/lib/services/berth-pdf.service.ts:473-475`. When
|
||||
`Number.isFinite(n)` is false the apply loop `continue`s without
|
||||
pushing to `applied` and without warning. Combined with the
|
||||
"no appliable fields supplied" check (only fires when ALL drop), partial
|
||||
silent drops are invisible. Fix: collect dropped keys and surface them.
|
||||
|
||||
## Multi-tenant isolation hardening
|
||||
|
||||
- **document_sends row stores `interestId` without verifying port match** —
|
||||
`src/lib/services/document-sends.service.ts:422`. Audit-log pollution
|
||||
rather than data exposure (the recipient lookup is port-checked already).
|
||||
Fix: when `recipient.interestId` is set, fetch with
|
||||
`and(eq(interests.id, ...), eq(interests.portId, input.portId))` and
|
||||
throw if missing.
|
||||
|
||||
- **Storage proxy token does not bind to port_id** —
|
||||
`src/lib/storage/filesystem.ts:73-84`. ProxyTokenPayload is `{k, e, n,
|
||||
f?, c?}` with a global HMAC. The current "issuer always checks port
|
||||
first" relies on every issuer being correct in perpetuity. Fix: add a
|
||||
`p` (portId) claim and have the proxy route resolve key→owner row +
|
||||
assert `owner.portId === payload.p` before streaming.
|
||||
|
||||
- **Documenso webhook does not enforce port_id on document lookups** —
|
||||
`src/app/api/webhooks/documenso/route.ts:96-148`. Handlers dispatch by
|
||||
global `documensoId`. If two ports' documents were ever issued the
|
||||
same Documenso ID (replay across staging/prod, forwarded webhook from
|
||||
a foreign instance), the wrong port's interest could be mutated. The
|
||||
per-body `signatureHash` dedup is partial mitigation. Fix: either
|
||||
(a) include the originating Documenso instance/team in the lookup, or
|
||||
(b) verify `documents(documenso_id)` has a unique index port-wide.
|
||||
|
||||
## Recent expense work polish
|
||||
|
||||
- **renderReceiptHeader cursor math drifts after multi-step writes** —
|
||||
`src/lib/services/expense-pdf.service.ts:854`. After
|
||||
`doc.text(...)` with auto-flow, `doc.y` advances. Using `doc.y -
|
||||
headerH + 10` after the rect+stroke block computes against the
|
||||
post-rect position; works only because pdfkit's text-after-rect
|
||||
hasn't moved y yet. Headers may misalign on the first receipt page
|
||||
after a soft page break. Fix: capture `const baseY = doc.y` before
|
||||
drawing the rect and compute all subsequent offsets relative to it.
|
||||
|
||||
## Settings parsing
|
||||
|
||||
- **`loadRecommenderSettings` rejects string-shaped JSONB booleans** —
|
||||
`src/lib/services/berth-recommender.service.ts:116`. Postgres returns
|
||||
JSONB `true/false` as JS booleans, but if an admin saves `"true"`
|
||||
via a UI that wraps the value as a string, `asBool` returns null and
|
||||
the per-port override silently falls through to defaults. Not a
|
||||
security bug; a tuning footgun. Fix: accept `"true"`/`"false"` string
|
||||
forms in `asBool`.
|
||||
|
||||
# Audit-final v2 (post-merge platform-wide pass) deferred findings
|
||||
|
||||
A second comprehensive audit (security, routes, DB, integrations, UI/UX)
|
||||
ran after the merge. The high-impact items landed in commit
|
||||
`fix(audit-final-v2): platform-wide hardening` (or similar). Items below
|
||||
are deferred follow-ups.
|
||||
|
||||
## Routes / API
|
||||
|
||||
- **Saved-views routes lack `withPermission`** —
|
||||
`src/app/api/v1/saved-views/[id]/route.ts:4-5` and
|
||||
`src/app/api/v1/saved-views/route.ts:24`. Convention is
|
||||
`withAuth(withPermission(...))`. Verify the service applies
|
||||
`(ctx.userId, ctx.portId)` ownership filtering, then add either an
|
||||
explicit owner-only comment or wrap with a benign permission gate.
|
||||
|
||||
- **Custom-fields permission resource hardcoded to `clients`** —
|
||||
`src/app/api/v1/custom-fields/[entityId]/route.ts:15,29`. Custom fields
|
||||
attach to client / yacht / interest / berth / company, but the route
|
||||
always checks `clients.view` / `clients.edit`. A user with
|
||||
`companies.view` can read confidential company custom-field values via
|
||||
this endpoint (the service-level `customFieldDefinitions.portId` filter
|
||||
prevents cross-tenant access but not cross-resource within a tenant).
|
||||
Fix: split into per-entity routes, OR resolve `entityType` and gate on
|
||||
the matching permission inline.
|
||||
|
||||
- **`alerts/[id]/acknowledge|dismiss` ungated** —
|
||||
`src/app/api/v1/alerts/[id]/acknowledge/route.ts:6` etc. only `withAuth`,
|
||||
no `withPermission`. Verify the service requires user ownership; if
|
||||
not, gate on `reports.view_dashboard` or similar.
|
||||
|
||||
- **Public POST routes bypass service layer** —
|
||||
`src/app/api/public/interests/route.ts`, `…/website-inquiries/route.ts`,
|
||||
`…/residential-inquiries/route.ts`. These do extensive `tx.insert(...)`
|
||||
with hand-rolled audit logs (`userId: null as unknown as string`).
|
||||
Extract a `publicInterestService.create(...)` so the same code path is
|
||||
unit-testable and port-id discipline is uniform. Verify
|
||||
`audit_logs.user_id` is nullable (the cast pattern signals it is, but
|
||||
enforce in schema if not).
|
||||
|
||||
- **Inconsistent response shapes** — most endpoints return `{ data: ... }`,
|
||||
but `notifications/[notificationId]` returns `{ success: true }`,
|
||||
`website-inquiries` returns `{ id, deduped }`. Document a convention in
|
||||
CLAUDE.md and migrate.
|
||||
|
||||
- **`req.json()` without `parseBody` helper** — admin custom-fields
|
||||
routes use `await req.json(); schema.parse(body)` directly instead of
|
||||
the project's `parseBody(req, schema)` helper. Migrate for uniform
|
||||
400 error shapes.
|
||||
|
||||
## Documenso integration
|
||||
|
||||
- **v2 voidDocument endpoint may not match real API** —
|
||||
`src/lib/services/documenso-client.ts:450-466`. The audit flagged that
|
||||
Documenso 2.x exposes envelope deletion as
|
||||
`POST /api/v2/envelope/delete` with `{ envelopeId }` body, not
|
||||
`DELETE /api/v2/envelope/{id}`. The unit test mocks fetch so it can't
|
||||
catch the real shape. Verify against a live Documenso 2.x instance
|
||||
(`pnpm exec playwright test --project=realapi`) before flipping any
|
||||
port to v2.
|
||||
|
||||
- **Webhook dedup vs per-recipient signed events** —
|
||||
`src/app/api/webhooks/documenso/route.ts:103-110`. The top-level
|
||||
`signatureHash` (sha256 of raw body) blocks exact replays, but a
|
||||
duplicate webhook delivery for a multi-recipient document with a
|
||||
re-encoded body will go through the per-recipient loop. Make
|
||||
`documentEvents.signatureHash` unique cover the suffixed values OR add
|
||||
a composite unique index `(documensoDocumentId, recipientEmail, eventType)`.
|
||||
|
||||
- **v1 `placeFields` per-field POST has no retry** —
|
||||
`src/lib/services/documenso-client.ts:374-398`. A single transient 500
|
||||
mid-loop leaves the document with a partial field set. Add 3-attempt
|
||||
exponential backoff on 5xx + voidDocument on final failure.
|
||||
|
||||
## Storage
|
||||
|
||||
- **S3 backend has no startup bucket-exists check** —
|
||||
`src/lib/storage/s3.ts:100-111`. A typo'd bucket name surfaces as a
|
||||
500 inside a user-facing request rather than at boot. Add
|
||||
`await client.bucketExists(bucket)` in `S3Backend.create` with a clear
|
||||
error message.
|
||||
|
||||
- **Storage cache fingerprint includes encrypted secret** —
|
||||
`src/lib/storage/index.ts:158-159`. After a key rotation the old
|
||||
cached client survives until `resetStorageBackendCache()` is called
|
||||
(already called via the settings-write hook). Document the
|
||||
invariant or fingerprint on a content-hash that excludes encrypted
|
||||
material.
|
||||
|
||||
- **Filesystem dev HMAC silent fallback** —
|
||||
`src/lib/storage/filesystem.ts:309-332`. Two dev nodes started with
|
||||
different `BETTER_AUTH_SECRET` derive different secrets and reject
|
||||
each other's tokens. Log a one-line warn at backend boot in non-prod.
|
||||
|
||||
## DB schema
|
||||
|
||||
- **`berths.current_pdf_version_id` lacks Drizzle FK** —
|
||||
`src/lib/db/schema/berths.ts:83`. The FK exists in migration 0030
|
||||
but not in the schema source-of-truth, so `pnpm db:push` against an
|
||||
empty DB skips the constraint. Either add the FK with a deferred
|
||||
declaration or document that `db:push` is unsupported.
|
||||
|
||||
- **Missing indexes on FK columns** — `berthReservations.interestId`,
|
||||
`berthReservations.contractFileId`, `documents.fileId`,
|
||||
`documents.signedFileId`, `documentEvents.signerId`,
|
||||
`documentTemplates.sourceFileId`, `formSubmissions.formTemplateId`,
|
||||
`formSubmissions.clientId`, `documentSends.brochureId`,
|
||||
`documentSends.brochureVersionId`, `documentSends.sentByUserId`. Add
|
||||
`index(...)` declarations to avoid full-scan FK checks on parent
|
||||
delete.
|
||||
|
||||
- **`systemSettings` PK / unique-index drift** —
|
||||
`src/lib/db/schema/system.ts:119-133`. Schema declares only a
|
||||
`uniqueIndex` on `(key, port_id)` but the migration uses `key` as PK.
|
||||
`port_id` is nullable so `(key, port_id)` cannot serve as a PK with
|
||||
default NULLs-not-equal semantics. Reconcile: declare
|
||||
`primaryKey({ columns: [table.key, table.portId] })` (after making
|
||||
`portId` non-null with a sentinel) OR use partial unique indexes for
|
||||
global + per-port settings.
|
||||
|
||||
- **Composite vs partial archived indexes** — many tables use
|
||||
`index('idx_*_archived').on(portId, archivedAt)` when the dominant
|
||||
query is `WHERE port_id = ? AND archived_at IS NULL`. Convert to
|
||||
`index(...).on(portId).where(sql\`archived_at IS NULL\`)` partial
|
||||
indexes for smaller storage + faster planner choice.
|
||||
|
||||
- **`documentSends.sentByUserId` ungated FK** —
|
||||
`src/lib/db/schema/brochures.ts:118` is `notNull()` but has no FK
|
||||
reference. If a user is hard-deleted (rare; we soft-delete), an
|
||||
orphan id remains. Add `.references(() => users.id, { onDelete: 'set null' })`
|
||||
and make the column nullable. Same audit-trail rationale as the
|
||||
other documentSends FK fixes (commit 0035).
|
||||
|
||||
## UI/UX
|
||||
|
||||
- **Storage admin migration mutation lacks toasts** —
|
||||
`src/components/admin/storage-admin-panel.tsx:61-72`. Add `onSuccess`
|
||||
toast with row count + `onError` toast.
|
||||
|
||||
- **Invoice detail send/payment mutations lack error feedback + gates** —
|
||||
`src/components/invoices/invoice-detail.tsx:93-99,152-167`. Add
|
||||
`onError: (e) => toast.error(...)` and wrap mutating buttons in
|
||||
`<PermissionGate resource="invoices" action="send">` /
|
||||
`record_payment`.
|
||||
|
||||
- **Admin user list edit button ungated** —
|
||||
`src/components/admin/users/user-list.tsx:114`. Wrap in
|
||||
`<PermissionGate resource="admin" action="manage_users">`.
|
||||
|
||||
- **Email threads list missing skeleton** —
|
||||
`src/components/email/email-threads-list.tsx:29-45`. Use `<Skeleton>`
|
||||
rows during load + `<EmptyState>` for the empty case.
|
||||
|
||||
- **Scan page mutations swallow OCR errors** —
|
||||
`src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx:67-87`. Add an
|
||||
inline error state for `scanMutation.isError` (the upload-side
|
||||
already does this).
|
||||
|
||||
- **Invoice detail uses `any` for query data** — strict-mode escape
|
||||
hatch. Define a proper response type matching the API contract.
|
||||
|
||||
## Security defense-in-depth
|
||||
|
||||
- **Storage proxy token does not bind to port_id** —
|
||||
`src/lib/storage/filesystem.ts:73-84`. Token's HMAC is global. Fix:
|
||||
add `p` (portId) claim and have the proxy resolve key→owner row +
|
||||
assert `owner.portId === payload.p`.
|
||||
|
||||
- **Documenso webhook does not enforce port_id** —
|
||||
`src/app/api/webhooks/documenso/route.ts:96-148`. Handlers dispatch
|
||||
by global `documensoId`. Verify `documents(documenso_id)` is unique
|
||||
port-wide OR include the originating instance/team in the lookup.
|
||||
|
||||
- **EOI in-app pathway silently swallows missing `Berth Range` field** —
|
||||
`src/lib/pdf/fill-eoi-form.ts:93`. Log warn when
|
||||
`context.eoiBerthRange` is non-empty AND the field is absent so the
|
||||
Documenso template deployment gap is observable.
|
||||
|
||||
- **AI worker has no cost-tracking ledger write** —
|
||||
`src/lib/queue/workers/ai.ts:122-177`. Persist token usage to the
|
||||
`ai_usage` ledger after every call.
|
||||
|
||||
- **Logger redact paths miss nested credentials** —
|
||||
`src/lib/logger.ts:5-19`. Extend redact list to cover
|
||||
`*.headers.authorization`, `**.token`, `secretKeyEncrypted`, etc.
|
||||
223
docs/audit-frontend-2026-05-06.md
Normal file
223
docs/audit-frontend-2026-05-06.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Frontend audit — 2026-05-06
|
||||
|
||||
Scope: new archive/restore/hard-delete dialogs, bulk archive wizard, client
|
||||
detail header, audit log inspector, webhook delivery log, client list bulk
|
||||
section. Companion to `docs/audit-comprehensive-2026-05-06.md` (does NOT
|
||||
re-flag the Files-tab / reservations / berth-tab "coming soon" stubs already
|
||||
covered there).
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1 — `client-detail-header` opens restore dialog from the Archive icon for archived clients
|
||||
|
||||
**File:** `src/components/clients/client-detail-header.tsx:174-186`
|
||||
|
||||
**Scenario:** On an archived client the icon button still renders `<Archive>`
|
||||
when `isArchived` is true (`isArchived ? <RotateCcw /> : <Archive />` is
|
||||
correct), BUT both states use the same `setArchiveOpen(true)` handler and
|
||||
the conditional below routes `<SmartRestoreDialog>` vs `<SmartArchiveDialog>`
|
||||
off of `isArchived`. That part is fine. The real problem: the destructive
|
||||
hover colour `hover:text-destructive` is applied via
|
||||
`isArchived ? 'hover:text-foreground' : 'hover:text-destructive'` — but the
|
||||
preceding class string already sets `hover:text-foreground` unconditionally,
|
||||
so the conditional is dead and the restore button hovers red the same as
|
||||
archive. Misleading colour signal on a reversible action; users hesitate to
|
||||
click it.
|
||||
|
||||
**Fix:** Drop the always-applied `hover:text-foreground` from the base class
|
||||
list and let the conditional own the hover colour, or just colour the
|
||||
restore icon emerald to differentiate.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H1 — `bulk-archive-wizard` lets users skip the reasons step by clicking Continue while preflight is loading then Cancel/reopen
|
||||
|
||||
**File:** `src/components/clients/bulk-archive-wizard.tsx:253-267, 80-107`
|
||||
|
||||
**Scenario:** In the `preflight` stage the Continue button is only disabled
|
||||
when `archivable.length === 0 || preflight.isLoading`. But `archivable` is
|
||||
derived from `items = preflight.data ?? []`. While loading, `archivable` is
|
||||
`[]` so Continue is disabled — good. After load with all-blocked selection,
|
||||
`archivable.length === 0` so still disabled — good. However, the
|
||||
`reasonsByClientId: reasons` payload is sent verbatim, so a user who advances
|
||||
to "reasons", types into one client's box, then uses the carousel back arrow
|
||||
and edits another, can submit reasons for clients NOT in `archivable` (e.g.
|
||||
if the preflight is refetched on stale-time). Reasons for blocked or removed
|
||||
client IDs are forwarded to the API. Minor data-quality issue.
|
||||
|
||||
**Fix:** Filter `reasons` to `archivable` IDs before mutating:
|
||||
`reasonsByClientId: Object.fromEntries(Object.entries(reasons).filter(([id]) => archivable.some(a => a.clientId === id)))`.
|
||||
|
||||
### H2 — `client-list` bulk tag mutation uses `alert()` for partial failures and has no `onError`
|
||||
|
||||
**File:** `src/components/clients/client-list.tsx:88-106`
|
||||
|
||||
**Scenario:** User bulk-adds a tag to 50 clients; backend returns 200 with
|
||||
`{succeeded: 30, failed: 20}` → user sees a native browser `alert()` blocking
|
||||
the page. If the request itself errors (network drop, 500), there is no
|
||||
`onError` so the dialog closes via `onSettled` and the user sees nothing —
|
||||
silent failure. Inconsistent UX vs. every other mutation in this audit which
|
||||
uses `toast`.
|
||||
|
||||
**Fix:** Replace `alert(...)` with `toast.warning(...)`, add an
|
||||
`onError: (err) => toast.error(...)` branch matching the pattern used in
|
||||
`bulk-archive-wizard.tsx` and `bulk-hard-delete-dialog.tsx`.
|
||||
|
||||
### H3 — `webhook-delivery-log` swallows fetch errors silently
|
||||
|
||||
**File:** `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`
|
||||
|
||||
**Scenario:** Admin opens a webhook detail page while the API is down or the
|
||||
webhook was just deleted. `load()` catches and discards the error
|
||||
(`} catch { /* ignore */ }`). UI shows "Loading deliveries…" forever on the
|
||||
first load, or stays on the last successful page on subsequent loads, with
|
||||
no indication that anything failed. No error state, no toast, no retry.
|
||||
|
||||
**Fix:** Surface errors via `toast.error` and show an inline error state
|
||||
("Couldn't load deliveries — Retry") instead of swallowing.
|
||||
|
||||
### H4 — `audit-log-list` first-page fetch swallows errors and shows no error state
|
||||
|
||||
**File:** `src/components/admin/audit/audit-log-list.tsx:150-175`
|
||||
|
||||
**Scenario:** Filter form is fully interactive, user changes a date — request
|
||||
fires, server 500s. The `try/finally` has no `catch`, so the rejected promise
|
||||
becomes an unhandled rejection. The list shows whatever was previously
|
||||
loaded (or empty state), and the user has no idea their filter didn't apply.
|
||||
Same applies to `loadMore`.
|
||||
|
||||
**Fix:** Add `catch` blocks that set an error state and render an inline
|
||||
error banner above the table, with a Retry button.
|
||||
|
||||
### H5 — `audit-log-card` renders as a link to `href="#"` — clicking jumps the page
|
||||
|
||||
**File:** `src/components/admin/audit/audit-log-card.tsx:96`
|
||||
|
||||
**Scenario:** On mobile / card view the audit log entries become clickable
|
||||
cards with `href="#"`. Tapping any card scrolls the page to top and inserts
|
||||
`#` in the URL (back-button trap). There's no detail view to navigate to.
|
||||
|
||||
**Fix:** Either render a non-link wrapper (button or div) when no detail
|
||||
target exists, or link to a useful destination like
|
||||
`/{portSlug}/{entityType}/{entityId}` when the entity is resolvable.
|
||||
|
||||
### H6 — `smart-archive-dialog` `archiveMutation` doesn't invalidate the dossier or single-client query
|
||||
|
||||
**File:** `src/components/clients/smart-archive-dialog.tsx:197-212`
|
||||
|
||||
**Scenario:** User archives a client successfully. The dialog invalidates
|
||||
`['clients']`, `['berths']`, `['interests']` but NOT
|
||||
`['client-archive-dossier', clientId]` nor `['clients', clientId]`. If the
|
||||
parent screen (e.g. detail page) keeps the client query mounted, the
|
||||
detail header continues to show the client as un-archived until a hard
|
||||
reload. The Restore icon won't appear.
|
||||
|
||||
**Fix:** Add `qc.invalidateQueries({queryKey: ['clients', clientId]})` and
|
||||
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})` so a
|
||||
re-open re-fetches a fresh dossier (e.g. if user re-archives after restoring
|
||||
in the same session).
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M1 — `smart-archive-dialog` derives `interestId` from a name match against `primaryBerthMooring` — wrong key
|
||||
|
||||
**File:** `src/components/clients/smart-archive-dialog.tsx:158-167`
|
||||
|
||||
**Scenario:** When building per-berth decisions the code does
|
||||
`dossier.interests.find((i) => i.primaryBerthMooring === b.mooringNumber)?.interestId`.
|
||||
Multiple interests can share the same primary mooring (rare, but possible
|
||||
historically), and worse, when no interest has this berth as primary it
|
||||
falls back to `dossier.interests[0]?.interestId` regardless of which berth
|
||||
is being decided. The wrong interest gets credited with the release, which
|
||||
is then audit-logged.
|
||||
|
||||
**Fix:** Have the dossier API return `interestId` per berth row (it already
|
||||
joins `interest_berths`), or look up by membership not by primary flag.
|
||||
|
||||
### M2 — `hard-delete-dialog` doesn't reset state when switching from intent → confirm if request fails midway
|
||||
|
||||
**File:** `src/components/clients/hard-delete-dialog.tsx:39-46, 64-79`
|
||||
|
||||
**Scenario:** User submits hard delete with wrong code → backend returns 400
|
||||
→ toast fires, but the dialog stays on `confirm` stage with the bad code
|
||||
still in the input and no clear cue. If the user then closes (X) and
|
||||
reopens, the `useEffect` resets correctly. But if the email code expired
|
||||
(10 min) and they request a fresh one, there's no "Resend code" button —
|
||||
they must cancel and start over from intent. Minor.
|
||||
|
||||
**Fix:** Add a "Send a new code" link in the confirm stage that calls
|
||||
`requestCode.mutate()` again and clears `code`.
|
||||
|
||||
### M3 — `bulk-hard-delete-dialog` doesn't refetch / invalidate after partial failure shows totals
|
||||
|
||||
**File:** `src/components/clients/bulk-hard-delete-dialog.tsx:64-85`
|
||||
|
||||
**Scenario:** Bulk delete returns `{deletedCount: 7}` for 10 selected; toast
|
||||
warns but `qc.invalidateQueries({queryKey: ['clients']})` is invalidated
|
||||
unconditionally — fine. However, the dialog closes immediately
|
||||
(`onOpenChange(false)`), so the user can't see WHICH 3 failed. The toast
|
||||
just says "see audit log". For a destructive bulk op this is too sparse;
|
||||
users will repeat the action thinking it didn't work.
|
||||
|
||||
**Fix:** Stay open on partial failure and render a list of failed IDs (the
|
||||
API likely already returns per-item results — if not, return them).
|
||||
|
||||
### M4 — `audit-log-list` doesn't validate that `dateFrom <= dateTo`
|
||||
|
||||
**File:** `src/components/admin/audit/audit-log-list.tsx:142-146`
|
||||
|
||||
**Scenario:** User picks From=2026-06-01, To=2026-05-01. Query fires with an
|
||||
empty result range; user sees "No audit log entries found" and assumes
|
||||
their data isn't there. No client-side validation hint.
|
||||
|
||||
**Fix:** Show an inline warning "From date must be before To date" and skip
|
||||
the request when invalid.
|
||||
|
||||
### M5 — `bulk-archive-wizard` `Cancel` during `archiveMutation.isPending` discards mutation tracking
|
||||
|
||||
**File:** `src/components/clients/bulk-archive-wizard.tsx:248-251, 293-307`
|
||||
|
||||
**Scenario:** User clicks "Archive 50" → mutation in flight (10s) → user
|
||||
clicks Cancel. The dialog closes; the mutation continues server-side and
|
||||
its onSuccess fires later, showing a toast for an action the user thought
|
||||
they cancelled. Worse, the dialog is gone so they can't tell which clients
|
||||
got archived.
|
||||
|
||||
**Fix:** Disable Cancel while `archiveMutation.isPending`, or relabel to
|
||||
"Cancel (won't stop in-progress)" and keep the mutation visible.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L1 — `audit-log-list` filter row overflows on narrow viewports
|
||||
|
||||
**File:** `src/components/admin/audit/audit-log-list.tsx:321-467`
|
||||
|
||||
**Scenario:** 8 filter controls (`Search` 288px, `Entity` 144px, `Action`
|
||||
176px, `Severity` 128px, `Source` 128px, `User id` 176px, `From` 144px,
|
||||
`To` 144px, total ~1330px) sit in a single `flex-wrap` row. At <1280px
|
||||
viewports they wrap onto multiple lines pushing the table down 200+px;
|
||||
at <640px (mobile) each control wraps onto its own line and the "Clear"
|
||||
button (`ml-auto`) lands on the wrong row.
|
||||
|
||||
**Fix:** Collapse rarely-used filters (User id / Severity / Source) into a
|
||||
"More filters" Popover for sm: viewports.
|
||||
|
||||
### L2 — `audit-log-card` action map missing entries silently fall back to grey "Activity" icon and grey badge
|
||||
|
||||
**File:** `src/components/admin/audit/audit-log-card.tsx:27-44, 46-52`
|
||||
|
||||
**Scenario:** New webhook/cron/job actions are in `audit-log-list.tsx`
|
||||
ACTION_COLORS but absent from `audit-log-card.tsx` ACTION_BADGE_COLORS and
|
||||
ACTION_ACCENT. Card view of these entries looks identical to a generic
|
||||
"unknown" entry — visual loss vs. table view.
|
||||
|
||||
**Fix:** Sync the two maps; consider extracting to a shared module so they
|
||||
can't drift.
|
||||
405
docs/audit-missing-features-2026-05-06.md
Normal file
405
docs/audit-missing-features-2026-05-06.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Missing-Features Audit — 2026-05-06
|
||||
|
||||
Focused pass on **features that look done in the UI but aren't fully
|
||||
wired through the service layer**, plus **admin settings exposed to
|
||||
users that no code reads**. Companion to
|
||||
`docs/audit-comprehensive-2026-05-06.md` — the three "coming soon" stubs
|
||||
already documented there (client Files tab, client reservations history,
|
||||
berth tabs), the import-worker stub, the two interest-form TODOs, and
|
||||
the EOI "Price: TBD" finding are NOT re-flagged here.
|
||||
|
||||
Hard cap: 12 findings. Severity tiers below.
|
||||
|
||||
---
|
||||
|
||||
## VISIBLE-BROKEN (admin sees a control, click is a no-op or wrong)
|
||||
|
||||
### V1. 6 of 8 admin-editable email subject overrides are silently ignored at send time
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/components/admin/email-templates-admin.tsx:24-72` (UI)
|
||||
- `src/lib/email/template-catalog.ts:16-25` (catalog of 8 keys)
|
||||
- `src/lib/services/portal-auth.service.ts:120-127, 332-339` (the only
|
||||
consumers of `loadSubjectOverride`)
|
||||
|
||||
The `/admin/email-templates` page lets an admin override the subject
|
||||
line on **eight** transactional templates:
|
||||
`portal_activation`, `portal_reset`, `portal_invite_resend`,
|
||||
`crm_invite`, `inquiry_client_confirmation`,
|
||||
`inquiry_sales_notification`, `residential_inquiry_client_confirmation`,
|
||||
`residential_inquiry_sales_alert`. The save endpoint persists each one
|
||||
to `system_settings` (`email_template_<key>_subject`).
|
||||
|
||||
Only **two** of those eight are ever read at send time —
|
||||
`portal_activation` and `portal_reset` in `portal-auth.service.ts`.
|
||||
A repo-wide search for `loadSubjectOverride` / `settingKeyForSubject`
|
||||
returns no other consumers. The other six templates use their hardcoded
|
||||
subject regardless of the admin override.
|
||||
|
||||
**Impact:** sales/ops teams will customize an inquiry confirmation
|
||||
subject, hit Save, see the "Overridden" badge, and silently ship the
|
||||
default subject to every prospect.
|
||||
|
||||
**Fix:** small per template — call `loadSubjectOverride(portId, key)`
|
||||
in each sender (`crm-invite.service.ts`, the inquiry sender, the
|
||||
residential inquiry sender, the portal-invite-resend path) and pass the
|
||||
result through as the email subject.
|
||||
|
||||
**Scope:** small (5 callsites + tests).
|
||||
|
||||
---
|
||||
|
||||
### V2. Branding admin (logo / app name / primary color / email header & footer HTML) saves to settings but no code reads them
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx:7-46` — UI
|
||||
with five fields.
|
||||
- `src/lib/services/port-config.ts:240-272` — `getPortBrandingConfig()`
|
||||
resolves the five `branding_*` settings into a typed config.
|
||||
- Repo-wide: `getPortBrandingConfig` has **zero callers** outside its
|
||||
declaration. The five `SETTING_KEYS.branding*` constants are only
|
||||
read inside `getPortBrandingConfig` itself.
|
||||
|
||||
The admin panel is functional end-to-end (write hits the settings API,
|
||||
"Reset to default" works), and the email-templates module hardcodes
|
||||
`s3.portnimara.com/...` for the logo URL plus a fixed table layout.
|
||||
None of the email-rendering helpers (`renderEmail`, the template
|
||||
modules in `src/lib/email/templates/`) call `getPortBrandingConfig`,
|
||||
and the `<BrandedAuthShell>` component sources its logo + colors from
|
||||
constants too.
|
||||
|
||||
**Impact:** every multi-tenant assumption made about branding is
|
||||
broken. A second port wired into this CRM will see Port Nimara's logo
|
||||
|
||||
- colors in every transactional email and on the auth pages, even
|
||||
after their admin "configures branding" successfully.
|
||||
|
||||
**Fix:** plumb `getPortBrandingConfig(portId)` through the email
|
||||
renderer (header/footer HTML + primary button color), and through
|
||||
`<BrandedAuthShell>` via a server-fetched prop.
|
||||
|
||||
**Scope:** medium (touches every transactional email + auth shell).
|
||||
|
||||
---
|
||||
|
||||
### V3. Reminder admin page configures defaults that no service applies
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx:7-50` — UI
|
||||
for default-enabled, default-days, digest-enabled, digest-time,
|
||||
digest-timezone.
|
||||
- `src/lib/services/port-config.ts:284-306` —
|
||||
`getPortReminderConfig()` defines the schema.
|
||||
- Repo-wide: the keys (`reminder_default_*`, `reminder_digest_*`) and
|
||||
`getPortReminderConfig` have **zero callers**.
|
||||
|
||||
Same pattern as V2. The admin sets "enable reminders by default on new
|
||||
interests" → toggles to true → save succeeds → newly-created interests
|
||||
still default to `reminderEnabled=false`. The digest-time +
|
||||
timezone fields go nowhere — there is no scheduler that batches
|
||||
pending reminders into a daily digest.
|
||||
|
||||
**Impact:** the entire reminder UX is decorative. Sales reps think
|
||||
they configured a daily digest at 09:00 Europe/Warsaw, get
|
||||
fire-as-they-hit notifications instead.
|
||||
|
||||
**Fix:** wire `getPortReminderConfig` into (a) the interest-create
|
||||
service (defaults), (b) the maintenance/notifications worker that
|
||||
fires reminders (digest batching + delivery window). The `digest`
|
||||
behavior didn't exist before this audit — needs a new scheduled job.
|
||||
|
||||
**Scope:** medium (defaults are small, digest job is new code).
|
||||
|
||||
---
|
||||
|
||||
### V4. Portal dashboard "My Memberships" tile has no link, no destination page, and isn't reachable from nav
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/(portal)/portal/dashboard/page.tsx:58-63` — `<PortalCard
|
||||
title="My Memberships" ... icon={Building2} />` — note no `href`
|
||||
prop.
|
||||
- `src/components/portal/portal-nav.tsx:8-15` — six nav entries, no
|
||||
memberships.
|
||||
- Filesystem: `src/app/(portal)/portal/memberships/` does not exist.
|
||||
|
||||
The dashboard shows a count of "memberships" (companies the portal
|
||||
user belongs to) but the tile is non-clickable and there is no
|
||||
`/portal/memberships` route. A user with 3 memberships sees the tile,
|
||||
clicks → nothing happens.
|
||||
|
||||
**Impact:** dead-end on the portal home for any client tied to a
|
||||
company (the residential and yacht-ownership use-cases).
|
||||
|
||||
**Fix:** ship `/portal/memberships/page.tsx` listing the companies
|
||||
returned by the existing `companyMemberships` query (already
|
||||
aggregated in `getPortalDashboard`), and add it to `PortalNav`. Or
|
||||
pull the tile if memberships isn't a portal feature.
|
||||
|
||||
**Scope:** small.
|
||||
|
||||
---
|
||||
|
||||
### V5. Company detail page Documents tab is a "Coming soon" stub
|
||||
|
||||
**File:** `src/components/companies/company-tabs.tsx:230-234`
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'documents',
|
||||
label: 'Documents',
|
||||
content: <EmptyState title="Documents" description="Coming soon" />,
|
||||
},
|
||||
```
|
||||
|
||||
Visible alongside the working Notes / Activity / Addresses / Members
|
||||
tabs on every company detail page. NOT covered by the existing audit
|
||||
doc's H7 (which lists clients, client reservations, and berths).
|
||||
|
||||
**Impact:** the same UX problem H7 calls out for clients.
|
||||
|
||||
**Fix:** mirror what client-Files-tab needs — query `documents` joined
|
||||
to a polymorphic billing-entity = company link, render a list, ship a
|
||||
download button. Or hide the tab.
|
||||
|
||||
**Scope:** small to medium.
|
||||
|
||||
---
|
||||
|
||||
## HALF-WIRED (the page works but the surrounding promise overstates it)
|
||||
|
||||
### V6. "Onboarding" admin page is a static checklist, not the wizard the page itself promises
|
||||
|
||||
**File:** `src/app/(dashboard)/[portSlug]/admin/onboarding/page.tsx`
|
||||
|
||||
The page renders 8 stepwise links and explicitly says (lines 71-72,
|
||||
98-110): "The future onboarding wizard will track progress per port…",
|
||||
"What this page will become", "The wizard will record completion per
|
||||
port in `system_settings`, gate the public marketing-site cutover…".
|
||||
|
||||
The admin landing card describes it as the "Initial-setup wizard for
|
||||
fresh ports" — admins clicking through expect a wizard, get a static
|
||||
table of contents.
|
||||
|
||||
**Impact:** the only "fresh port" workflow doesn't exist; cutover
|
||||
gating logic mentioned in the page body is also unimplemented.
|
||||
|
||||
**Fix:** either (a) build the wizard with progress in `system_settings`
|
||||
|
||||
- banner integration, or (b) re-label both this page and the admin
|
||||
landing card to "Setup checklist" so expectations match reality.
|
||||
|
||||
**Scope:** large for the wizard; tiny for the relabel.
|
||||
|
||||
---
|
||||
|
||||
### V7. Backup & Restore admin page is informational only — admin landing card promises actions
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/(dashboard)/[portSlug]/admin/backup/page.tsx`
|
||||
- `src/app/(dashboard)/[portSlug]/admin/page.tsx:148` — landing card
|
||||
description: "Database snapshots and on-demand exports."
|
||||
|
||||
The landing card sells "on-demand exports". The actual page renders a
|
||||
two-card explainer: "Current backup posture" (read-only) and "What
|
||||
this page will become" (the entire interactive surface — list
|
||||
snapshots, "Take backup now" button, per-port logical export, restore
|
||||
preview, GDPR per-client export). None of those exist.
|
||||
|
||||
**Impact:** the "Backup & Restore" tile is functionally a docs page.
|
||||
Compliance officers / users expecting a self-serve GDPR export
|
||||
button have to file a support ticket.
|
||||
|
||||
**Fix:** match the language on the landing card to the page reality
|
||||
("Backup posture" → docs only) until the snapshot/export buttons
|
||||
ship. The maintenance worker already runs `database-backup` (per
|
||||
`docs/audit-comprehensive-2026-05-06.md` C1 — though that worker isn't
|
||||
imported), so wiring "Take backup now" against the existing job is
|
||||
small once C1 is fixed.
|
||||
|
||||
**Scope:** small (doc tweak) or medium (button + per-port export
|
||||
endpoint).
|
||||
|
||||
---
|
||||
|
||||
### V8. Inquiry inbox is read-only — no "Convert to Client" / "Mark resolved" / "Assign" actions
|
||||
|
||||
**File:** `src/components/admin/inquiry-inbox.tsx` (entire file, 207
|
||||
lines, ends at the View payload toggle)
|
||||
|
||||
The inbox lists website-form submissions (berth_inquiry,
|
||||
residence_inquiry, contact_form) with filter chips and a
|
||||
"View payload" expand. There is no action to:
|
||||
|
||||
- create a client/interest from the submission,
|
||||
- assign the inquiry to a sales rep,
|
||||
- mark it resolved / triaged,
|
||||
- reply directly,
|
||||
- archive or trash the row,
|
||||
- export.
|
||||
|
||||
The `website_submissions` table appears to be permanent — every
|
||||
inquiry ever received remains in the inbox forever, with no triage
|
||||
state. Sales has to manually copy the email into a new client form
|
||||
and back-reference the original submission.
|
||||
|
||||
**Impact:** the inquiry-to-pipeline conversion step isn't supported in
|
||||
the CRM. The marketing-site cutover (per the user's
|
||||
`project_email_ownership_at_cutover.md` memory) will increase volume
|
||||
on this surface and make the missing triage UX painful.
|
||||
|
||||
**Fix:** add a per-submission "Convert" action that prefills the
|
||||
client + interest forms with the payload, plus a `triage_state`
|
||||
column (open / converted / dismissed) and a default filter that hides
|
||||
non-open rows.
|
||||
|
||||
**Scope:** medium.
|
||||
|
||||
---
|
||||
|
||||
## MOBILE PARITY
|
||||
|
||||
### V9. Mobile More-sheet is missing several real top-nav destinations
|
||||
|
||||
**File:** `src/components/layout/mobile/more-sheet.tsx:38-50`
|
||||
|
||||
`MORE_ITEMS` lists 11 entries. The dashboard route directory has at
|
||||
least these top-level segments not represented anywhere in the mobile
|
||||
bottom-tabs OR more-sheet:
|
||||
|
||||
- `residential` — exists at `/[portSlug]/residential/...`
|
||||
- `notifications` — exists at `/[portSlug]/notifications/...`
|
||||
- `berth-reservations` — exists at `/[portSlug]/berth-reservations/...`
|
||||
- `documents` — exists as a top-level page (separate from the bottom
|
||||
tab `documents`, which IS in mobile-bottom-tabs)
|
||||
- `website-analytics` — exists at `/[portSlug]/website-analytics/...`
|
||||
|
||||
A mobile-only user has no path to any of them. The Documents bottom
|
||||
tab does cover the doc list, but residential is an entire feature
|
||||
domain (per the `(dashboard)/.../residential` directory) with no
|
||||
mobile entry point.
|
||||
|
||||
**Impact:** anyone using the mobile chrome to triage on the go can't
|
||||
reach residential clients/interests, alerts (`alerts` IS in the
|
||||
sheet), or notifications.
|
||||
|
||||
**Fix:** add the missing segments to `MORE_ITEMS`. If the grid feels
|
||||
too dense, reorganize into sections.
|
||||
|
||||
**Scope:** small.
|
||||
|
||||
---
|
||||
|
||||
### V10. Portal has no "Profile" / "Change password" surface
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/components/portal/portal-nav.tsx:8-15` — six tabs, no profile.
|
||||
- Filesystem: no `src/app/(portal)/portal/profile/` directory.
|
||||
|
||||
A portal user who wants to change their email, phone, mailing address,
|
||||
or password has no UI. The portal sign-in flow goes through the
|
||||
better-auth session but the app exposes zero account-management
|
||||
controls. The "Need assistance?" card on the dashboard tells the user
|
||||
to contact the port team — which is the explicit answer for data
|
||||
edits, but does not cover password changes (a security expectation,
|
||||
not a per-port-staff burden).
|
||||
|
||||
**Impact:** every portal user who forgets their password (after
|
||||
already activating) has to use `/portal/forgot-password` even if they
|
||||
remember the old one. There's no proactive password rotation. A user
|
||||
who changes their phone number has to email the port to update it.
|
||||
|
||||
**Fix:** ship `/portal/profile` with at minimum: read-only PII view +
|
||||
"Change password" form (re-uses the existing reset-password endpoint
|
||||
or a new `change-password` endpoint that takes the current pw).
|
||||
Phone/address editing is a longer fix because of the audit-trail
|
||||
implications.
|
||||
|
||||
**Scope:** small for password; medium with PII edits.
|
||||
|
||||
---
|
||||
|
||||
### V11. Portal invoices page lists invoices but offers no view/download — even though documents do
|
||||
|
||||
**File:** `src/app/(portal)/portal/invoices/page.tsx:53-99`
|
||||
|
||||
Each invoice row shows number, status, due/paid dates, amount, and a
|
||||
small payment-status caption. There is no link, no PDF view, no
|
||||
download. By contrast, the portal Documents page (peer route) ends
|
||||
each row with a `<DocumentDownloadButton documentId={doc.id} />` that
|
||||
fetches a signed S3 URL.
|
||||
|
||||
Compare to admin/CRM where invoices have a full PDF render flow
|
||||
(invoice service generates the PDF + signed URL).
|
||||
|
||||
**Impact:** a portal user can see they owe money and cannot retrieve
|
||||
the actual invoice document. They have to email the port to ask for a
|
||||
PDF copy.
|
||||
|
||||
**Fix:** add an invoice-PDF endpoint under `/api/portal/invoices/[id]/
|
||||
download` mirroring the documents one, and a download button on each
|
||||
row. The invoice PDF generator already exists (`src/lib/services/
|
||||
invoices.ts`).
|
||||
|
||||
**Scope:** small.
|
||||
|
||||
---
|
||||
|
||||
## DEV-NOTES (legitimately staged-for-later, calling out so they're not forgotten)
|
||||
|
||||
### V12. Email-templates admin only edits subject lines — body editing is a documented "next iteration"
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/components/admin/email-templates-admin.tsx:78-79` —
|
||||
"Customize the subject line of transactional emails per port. Body
|
||||
editing is the next iteration; for now the layout and HTML stay
|
||||
locked to the default template."
|
||||
- `src/lib/email/template-catalog.ts:5-9` — same statement in the
|
||||
catalog header.
|
||||
|
||||
The page is honest about the limitation, so this isn't a "broken"
|
||||
finding. But it's a notable shipped-without-the-killer-feature gap:
|
||||
the multi-tenant promise of per-port email customization can't deliver
|
||||
the body changes that ports actually want (logo placement, signature,
|
||||
language). Combined with V2 (branding HTML fragments aren't read at
|
||||
all), there is currently NO way for a non-super-admin per-port admin
|
||||
to customize the email body in any way.
|
||||
|
||||
**Impact:** confined to admin expectations — most ports will assume
|
||||
"Email templates" = "edit the email", click in, see only a subject
|
||||
field, and request the missing body editor.
|
||||
|
||||
**Fix:** scope a body-editing flow that reuses the
|
||||
`merge_fields.ts` token catalog (the validator already exists for
|
||||
document templates) for safety. Until that's built, V2 + this finding
|
||||
together mean a "rebrand the emails" task is single-tenant only.
|
||||
|
||||
**Scope:** large (HTML editor + token validator + per-port override
|
||||
storage + render-side composition).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
12 findings, four severity tiers:
|
||||
|
||||
- **Visible-broken (V1-V5):** five admin/portal controls produce no
|
||||
effect. V1 (email overrides) and V2 (branding) are the highest
|
||||
impact — both silently break the multi-tenant promise.
|
||||
- **Half-wired (V6-V8):** three pages where the surrounding wrapper
|
||||
oversells what's there. V8 (inquiry inbox) is the largest scope.
|
||||
- **Mobile parity (V9-V11):** mobile users can't reach several real
|
||||
features; portal users have no profile/password surface and can't
|
||||
download invoices.
|
||||
- **Dev-notes (V12):** documented limitations called out for the
|
||||
roadmap.
|
||||
|
||||
The two highest-leverage quick wins are **V1** (wire 6 missing
|
||||
template subject overrides — a few hours) and **V11** (portal invoice
|
||||
download — small, fixes a real customer pain point).
|
||||
266
docs/audit-permissions-2026-05-06.md
Normal file
266
docs/audit-permissions-2026-05-06.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Per-role permission audit — 2026-05-06
|
||||
|
||||
Focused review of UI/server permission divergence on the new endpoints
|
||||
shipped during the smart-archive / hard-delete / bulk-wizard /
|
||||
external-EOI / webhook-replay work bundle. Skips items already covered
|
||||
in `docs/audit-comprehensive-2026-05-06.md` (audit-log gating H6,
|
||||
residential_partner sidebar nav).
|
||||
|
||||
The pattern hunted for: `<PermissionGate>` (or `usePermissions().can`)
|
||||
on the UI side hides a control under permission **X**, while the
|
||||
matching API route gates on permission **Y** (or doesn't gate at all,
|
||||
or gates strictly — producing 403 toast spam for users who can see the
|
||||
button but can't use it).
|
||||
|
||||
Scope: 8 routes + 5 components + the seed permission matrix. Hard cap
|
||||
of 10 findings, ranked by impact. Critical/High/Medium/Low.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
_None._ The four new hard-delete endpoints all gate on
|
||||
`admin.permanently_delete_clients` on both layers (UI hides the button
|
||||
via `<PermissionGate resource="admin" action="permanently_delete_clients">`
|
||||
in `client-detail-header.tsx:162` and via `canHardDelete = can('admin',
|
||||
'permanently_delete_clients')` in `client-list.tsx:53`; the four routes
|
||||
all wrap with `withPermission('admin', 'permanently_delete_clients', …)`).
|
||||
The webhook-replay route gates on `admin.manage_webhooks` — see H1 below
|
||||
for the matching UI gap.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1. Webhook replay button has no UI permission gate (403 toast for non-admins)
|
||||
|
||||
- **UI:** `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
|
||||
— the Replay `<Button>` renders for any user who can load the page,
|
||||
with no `<PermissionGate>` wrapper and no `usePermissions().can('admin',
|
||||
'manage_webhooks')` check.
|
||||
- **Server:** `src/app/api/v1/admin/webhooks/[webhookId]/deliveries/[deliveryId]/redeliver/route.ts:15`
|
||||
— `withPermission('admin', 'manage_webhooks', …)`.
|
||||
|
||||
**Divergence:** A `sales_manager` / `sales_agent` / `viewer` who
|
||||
somehow lands on `/admin/webhooks/{id}` (e.g. via a deep link from a
|
||||
shared message) sees enabled Replay buttons. Clicking surfaces a
|
||||
generic 403 toast — the user has no signal that the action is
|
||||
restricted, just that "Replay failed".
|
||||
|
||||
**Fix:** wrap the Replay `<Button>` in
|
||||
`<PermissionGate resource="admin" action="manage_webhooks">…</PermissionGate>`,
|
||||
or skip rendering the entire "Replay" column when
|
||||
`!can('admin', 'manage_webhooks')`. The page-level guard on
|
||||
`/admin/webhooks` should prevent non-admins from reaching the route in
|
||||
the first place, but defense-in-depth is cheap and the toast UX is
|
||||
poor.
|
||||
|
||||
---
|
||||
|
||||
### H2. Bulk-archive bulk action exposed to roles without `clients.delete`
|
||||
|
||||
- **UI:** `src/components/clients/client-list.tsx:182-190` — the
|
||||
"Archive" entry in `bulkActions` is unconditionally rendered (only
|
||||
the "Permanently delete" entry checks `canHardDelete`).
|
||||
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — gates
|
||||
`archive` action on `clients.delete`. Also
|
||||
`src/app/api/v1/clients/bulk-archive-preflight/route.ts:30` —
|
||||
`withPermission('clients', 'delete', …)`.
|
||||
|
||||
**Divergence:** `sales_agent` (`clients.delete:false`,
|
||||
seed-permissions.ts:246) and `viewer` (`clients.delete:false`,
|
||||
seed-permissions.ts:323) both see the Archive bulk action. Selecting
|
||||
clients and pressing it fires the `BulkArchiveWizard`, which calls
|
||||
`bulk-archive-preflight` (returns 403) followed by `bulk` archive
|
||||
(also 403). The wizard surfaces this as an opaque error.
|
||||
|
||||
**Fix:** mirror the `canHardDelete` pattern — compute
|
||||
`const canBulkArchive = can('clients', 'delete');` near
|
||||
`client-list.tsx:53` and conditionally include the Archive entry.
|
||||
|
||||
---
|
||||
|
||||
### H3. Bulk add_tag / remove_tag exposed to viewer (clients.edit:false)
|
||||
|
||||
- **UI:** `src/components/clients/client-list.tsx:165-181` — the "Add
|
||||
tag" / "Remove tag" bulk actions render with no permission check.
|
||||
- **Server:** `src/app/api/v1/clients/bulk/route.ts:40-57` — both gate
|
||||
on `clients.edit`.
|
||||
|
||||
**Divergence:** A `viewer` can multi-select rows, click "Add tag" or
|
||||
"Remove tag", pick a tag in the dialog, hit "Apply", and receive a 403. The standalone bulk tag dialog has no inline gating to prevent
|
||||
this.
|
||||
|
||||
**Fix:** the bulk action menu entries should gate on
|
||||
`can('clients', 'edit')`. (Sales agent and above pass; only `viewer`
|
||||
and `residential_partner` see the bug.)
|
||||
|
||||
---
|
||||
|
||||
### H4. `client-merge-log.surviving_client_id` enforcement absent from per-row port check on bulk hard-delete
|
||||
|
||||
- **Server:** `src/lib/services/client-hard-delete.service.ts:269-272`
|
||||
|
||||
The bulk preflight loads **every** row in the port
|
||||
(`db.select(...).from(clients).where(eq(clients.portId, args.portId))`)
|
||||
into memory, then validates the requested `clientIds` against that map.
|
||||
That's correct for tenant isolation — a foreign-port id can't appear in
|
||||
the map — but the inner loop at lines 364-389 then re-fetches each
|
||||
client by `(id, portId)` and **silently skips** rows where the second
|
||||
fetch returns nothing (line 377: `if (!c) continue;`). If a client is
|
||||
archived between preflight and execute by another operator, the bulk
|
||||
delete reports `deletedCount` lower than the requested set with no
|
||||
error — the operator has no way to tell which ids were skipped.
|
||||
|
||||
**Divergence (perm-adjacent):** the per-row gate is enforced for
|
||||
tenancy but the failure mode masquerades as success. Combined with
|
||||
the route's all-or-nothing `withPermission` at the top, a
|
||||
`permanently_delete_clients`-bearing operator can quietly under-delete.
|
||||
|
||||
**Fix:** when `c` is null, push the id into a `skipped: string[]`
|
||||
array and return it in the response so the UI can surface "3
|
||||
deleted, 1 skipped (not archived / removed by another user)".
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M1. `external-eoi` upload allows any role with `documents.upload_signed` regardless of `interests.edit`
|
||||
|
||||
- **UI:** `src/components/interests/interest-detail-header.tsx:382-395`
|
||||
— `<PermissionGate resource="documents" action="upload_signed">`.
|
||||
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8`
|
||||
— `withPermission('documents', 'upload_signed', …)`.
|
||||
|
||||
**Divergence:** UI and server agree on the permission, but the seed
|
||||
matrix has `documents.upload_signed:true` for `sales_agent` (line 264) AND any custom role with that flag — uploading an externally
|
||||
signed EOI mutates the **interest** (it's the operative `signedDocument`
|
||||
that flips the interest into a "signed" state inside
|
||||
`uploadExternallySignedEoi`). The user only needs `documents.upload_signed`,
|
||||
not `interests.edit`. A custom role with `documents.upload_signed:true`
|
||||
|
||||
- `interests.edit:false` can mutate the interest's effective state.
|
||||
|
||||
**Fix:** add a second gate inside the route handler:
|
||||
`if (!ctx.isSuperAdmin && !ctx.permissions?.interests?.edit) throw new ForbiddenError(...)`.
|
||||
Rationale: signing a doc against an interest is an interest-state
|
||||
change, not just a document upload. Mirror the same check in
|
||||
`<PermissionGate>` (use `<PermissionGate resource="interests" action="edit">`
|
||||
nested inside the `documents.upload_signed` gate).
|
||||
|
||||
---
|
||||
|
||||
### M2. `change_stage` UI doesn't expose override checkbox in `InlineStagePicker` — server still accepts override
|
||||
|
||||
- **UI:** `src/components/interests/inline-stage-picker.tsx:52-58` —
|
||||
the inline picker (used in the detail header at
|
||||
`interest-detail-header.tsx:221`) sends only
|
||||
`{ pipelineStage, reason }` and never sets `override:true`. Users
|
||||
with `override_stage` get no UI affordance to actually use the
|
||||
permission from the inline picker; they have to open the modal
|
||||
`InterestStagePicker` (which does expose the checkbox at line 137).
|
||||
Worse, when a user picks a stage that isn't a legal forward
|
||||
transition, the inline picker just shows the toast from the server's
|
||||
`ConflictError` — instead of "you need override; toggle this box".
|
||||
- **Server:** `src/app/api/v1/interests/[id]/stage/route.ts:14-22` —
|
||||
reads `body.override` and re-checks `interests.override_stage`
|
||||
permission.
|
||||
|
||||
**Divergence:** UI and permission map diverge in the affordance, not
|
||||
the gate. End-result: the `override_stage` permission is partially
|
||||
unreachable from the inline picker. Sales managers / agents can
|
||||
override only via the modal picker.
|
||||
|
||||
**Fix:** when the inline picker sees a transition that isn't allowed
|
||||
by `canTransitionStage(currentStage, newStage)`, check
|
||||
`can('interests', 'override_stage')` and either auto-set
|
||||
`override:true` (with a confirmation) or surface a "Use override"
|
||||
secondary action. Keep the inline picker UX; just don't let the
|
||||
override permission be silently inaccessible from the most-used
|
||||
path.
|
||||
|
||||
---
|
||||
|
||||
### M3. `sales_agent` granted `interests.override_stage:true` — possible copy-paste from sales_manager
|
||||
|
||||
- **Seed:** `src/lib/db/seed-permissions.ts:253` — `SALES_AGENT_PERMISSIONS.interests.override_stage = true`.
|
||||
|
||||
This is identical to `SALES_MANAGER_PERMISSIONS.interests.override_stage = true`
|
||||
at line 176. The same `sales_agent` block has `delete:false` for
|
||||
clients/interests/yachts/companies/files/etc — all the other
|
||||
"trust-elevated" flags are explicitly stripped from sales_agent. The
|
||||
ability to bypass the pipeline-stage transition table is a meaningful
|
||||
trust elevation: it lets an agent skip prerequisites (e.g. mark an
|
||||
interest as `eoi_signed` without an actual signed doc) which has
|
||||
downstream implications for the public berths feed (`Under Offer`
|
||||
status), the recommender's tier ladder, and the EOI bundle.
|
||||
|
||||
**Divergence:** likely intent vs. permission map. Worth confirming
|
||||
with a product owner; if intentional, leave a code comment. If
|
||||
unintentional, flip to `false`.
|
||||
|
||||
**Fix:** product decision. If demoted, also update
|
||||
`src/components/admin/roles/role-form.tsx → DEFAULT_PERMISSIONS`
|
||||
(noted in the file header at seed-permissions.ts:9) so the UI
|
||||
default for new roles matches.
|
||||
|
||||
---
|
||||
|
||||
### M4. `bulk-archive-preflight` returns dossier even when client is in another port (defense-in-depth)
|
||||
|
||||
- **Server:** `src/app/api/v1/clients/bulk-archive-preflight/route.ts:33-62`
|
||||
|
||||
The route loops through `ids` and calls `getClientArchiveDossier(id, ctx.portId)`
|
||||
for each. If a `clientId` belongs to another port, `getClientArchiveDossier`
|
||||
throws and the route catches it (line 52-61) and returns a fallback row
|
||||
with `blockers: ['<error message>']`. This leaks **the existence of an
|
||||
unknown client id** — an attacker enumerating UUIDs can distinguish
|
||||
"client doesn't exist" from "client exists but you can't see it" by
|
||||
parsing the blocker text. The bulk hard-delete route has the same
|
||||
shape but returns `NotFoundError`.
|
||||
|
||||
**Divergence (perm-adjacent):** the preflight route doesn't enforce a
|
||||
per-id port check before falling through to the dossier service, and
|
||||
the catch block leaks the failure mode in the response.
|
||||
|
||||
**Fix:** in the catch block, replace the dossier error message with a
|
||||
generic `'Could not load dossier'` blocker. The operator already
|
||||
selected these ids so they know the count; they don't need the inner
|
||||
error.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### L1. `external-eoi` route doesn't enforce `interests.edit` defense-in-depth on the interest port
|
||||
|
||||
- **Server:** `src/app/api/v1/interests/[id]/external-eoi/route.ts:8-14`
|
||||
|
||||
The route receives `interestId` from the URL and passes it +
|
||||
`ctx.portId` into `uploadExternallySignedEoi`. The service is
|
||||
expected to enforce port isolation, but the route itself does no
|
||||
upfront `(interestId, portId)` existence check before reading the
|
||||
multipart body — meaning a cross-port id will fully process the
|
||||
upload (read the file into memory) before the service rejects.
|
||||
|
||||
**Divergence:** not strictly a permission divergence; it's resource
|
||||
waste from missing early port-ownership check. Low because the
|
||||
service-level reject does close the security hole.
|
||||
|
||||
**Fix:** add a one-row `select` on `interests` matching `id` + `portId`
|
||||
before parsing form data, throw `NotFoundError` on miss.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- 0 critical
|
||||
- 4 high (H1–H4)
|
||||
- 4 medium (M1–M4)
|
||||
- 1 low (L1)
|
||||
|
||||
Top recommendation: H1 (webhook-replay UI gate) is a
|
||||
ten-line fix that closes a 403-toast UX bug. H2 + H3 (bulk-archive +
|
||||
bulk-tag UI gates) are also trivial and remove the same class of bug
|
||||
across the bulk actions menu. M3 (sales_agent override_stage) needs a
|
||||
product decision, not code; flag it before shipping the audit.
|
||||
220
docs/audit-reliability-2026-05-06.md
Normal file
220
docs/audit-reliability-2026-05-06.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Reliability audit — 2026-05-06 (focused, post-batch deltas)
|
||||
|
||||
Scope: NEW services from the recent archive/restore/hard-delete/external-EOI batches.
|
||||
Out of scope (already covered in `docs/audit-comprehensive-2026-05-06.md`):
|
||||
worker imports, rate limits, hard-delete error message UX, smart-restore
|
||||
dead reversal applier, bulk hard-delete redis loop, audit log spam.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1. Bulk archive enqueues zero post-commit side effects
|
||||
|
||||
- **File:** `src/app/api/v1/clients/bulk/route.ts:68-134`
|
||||
- **Scenario:** When the bulk wizard archives 100 clients with high-stakes
|
||||
reasons, `archiveClientWithDecisions` returns `externalCleanups` and
|
||||
`releasedBerths` arrays per-client, but `runBulk` discards the return
|
||||
value. Documenso envelopes that the wizard marked `void_documenso`
|
||||
never get queued, and "next-in-line" notifications never fire. The
|
||||
database is left in `documents.status='cancelled'` with the live
|
||||
Documenso envelope still out for signature — the signer can complete
|
||||
a legally-binding envelope that the CRM thinks is voided.
|
||||
- **Fix:** Make the per-row callback return the result, then loop over
|
||||
`results` after `runBulk` to enqueue Documenso voids and fire
|
||||
next-in-line notifications (mirroring the single-client route).
|
||||
Defaulting `documentDecisions` to `'leave'` (line 113-116) hides the
|
||||
symptom for the bulk wizard but isn't enough — the single-client
|
||||
service can still surface this if the bulk path is ever generalized.
|
||||
|
||||
---
|
||||
|
||||
## High
|
||||
|
||||
### H1. Restore wizard silently drops every released berth
|
||||
|
||||
- **File:** `src/lib/services/client-restore.service.ts:359-372`
|
||||
- **Scenario:** `applyReversal` for `berth_released` is a no-op with a
|
||||
comment saying "v1 leaves the berth available". But the dossier (line
|
||||
122-129) classifies these as `autoReversible` and the UI tells the
|
||||
operator "still available — re-attaching to the restored client". The
|
||||
wizard increments `autoReversed` and the audit log records a
|
||||
successful auto-reverse — but nothing actually happens. Operator
|
||||
thinks restore re-linked their berth; it didn't.
|
||||
- **Fix:** Either (a) actually re-link by persisting the original
|
||||
`interestId` in the `berth_released` decision detail (it's already
|
||||
there, line 211) and re-inserting an `interestBerths` row + flipping
|
||||
the berth status back to `under_offer`, or (b) reclassify these as
|
||||
`reversibleWithPrompt` with copy that says "berth left available —
|
||||
re-add via the interest detail page".
|
||||
|
||||
### H2. Smart-archive berth status update has TOCTOU race
|
||||
|
||||
- **File:** `src/lib/services/client-archive.service.ts:191-207`
|
||||
- **Scenario:** Berth row is read via `dossier.berths` (read outside the
|
||||
tx) and modified inside the tx without a `for update` lock on
|
||||
`berths`. Two concurrent flows — e.g. operator A archives client X
|
||||
while operator B sells berth A1 to client Y — can race: A reads
|
||||
`berth.status === 'sold' → false`, B's tx commits sold, A's tx then
|
||||
flips it back to `available`. The "still under offer" subselect
|
||||
doesn't catch this because berth.status is the source of truth, not
|
||||
interest_berths.
|
||||
- **Fix:** Add `tx.select(...).from(berths).where(eq(berths.id, d.berthId)).for('update')`
|
||||
before the status flip and re-check `status !== 'sold'` against the
|
||||
locked row.
|
||||
|
||||
### H3. Bulk archive can pick the wrong interest for berth release
|
||||
|
||||
- **File:** `src/app/api/v1/clients/bulk/route.ts:95-103`
|
||||
- **Scenario:** When a client has multiple interests linked to the same
|
||||
berth, the bulk wizard picks `dossier.interests.find((i) =>
|
||||
i.primaryBerthMooring === b.mooringNumber)` and falls back to
|
||||
`dossier.interests[0]?.interestId ?? ''`. The fallback to the
|
||||
first-interest-or-empty-string can hand `archiveClientWithDecisions`
|
||||
an `interestId` that was never linked to that berth — so the
|
||||
`delete from interest_berths where berthId=… and interestId=…`
|
||||
matches zero rows and the link is silently retained. Worse: an empty
|
||||
string `''` reaches the delete, which still matches zero rows but
|
||||
leaves the berth status check believing the link was removed.
|
||||
- **Fix:** Build the berth→interest map from `interestBerthRows` (the
|
||||
authoritative join) rather than guessing by `primaryBerthMooring`,
|
||||
and skip berths with no resolvable interest rather than emitting an
|
||||
empty-string interestId.
|
||||
|
||||
### H4. External EOI runs four writes outside a transaction
|
||||
|
||||
- **File:** `src/lib/services/external-eoi.service.ts:67-155`
|
||||
- **Scenario:** `getStorageBackend().put()`, `files.insert`,
|
||||
`documents.insert`, `documentEvents.insert`, and the interests
|
||||
update happen as five independent operations. If any one fails after
|
||||
the storage upload, you're left with an orphan PDF in S3/MinIO and
|
||||
partial DB state. If the documents insert fails after the file
|
||||
insert, the file row points to a storage key with no document
|
||||
referencing it — and the interest never advances.
|
||||
- **Fix:** Wrap files/documents/documentEvents/interests in a single
|
||||
`db.transaction`. Storage upload stays outside (S3 isn't
|
||||
transactional) but on tx failure, schedule a cleanup job that deletes
|
||||
the orphan storage object, or accept the orphan and add a janitor.
|
||||
|
||||
### H5. Bulk wizard double-submit re-archives the same client and racy errors
|
||||
|
||||
- **File:** `src/app/api/v1/clients/bulk/route.ts:68-120` +
|
||||
`src/lib/services/client-archive.service.ts:165-173`
|
||||
- **Scenario:** The single-client `archiveClientWithDecisions` locks
|
||||
the row and throws `ConflictError('Client is already archived')` on
|
||||
re-entry — good. But `runBulk` swallows the error string and returns
|
||||
it as `{ok:false, error:"Client is already archived"}` for that
|
||||
client. If the bulk wizard double-submits (network retry, double
|
||||
click), partial successes from the first request now look like
|
||||
per-client failures in the response, confusing the operator. There's
|
||||
no idempotency key on the bulk submit.
|
||||
- **Fix:** Treat `ConflictError('already archived')` as success in the
|
||||
bulk per-row handler (the desired end state is reached). Or add an
|
||||
idempotency-key header on the bulk endpoint that short-circuits a
|
||||
duplicate request with the cached response.
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### M1. Hard-delete `clientMergeLog.surviving_client_id` deletes audit history
|
||||
|
||||
- **File:** `src/lib/services/client-hard-delete.service.ts:209`
|
||||
- **Scenario:** The comment says "merged records remain in the log
|
||||
because mergedClientId has no FK", but the delete is wider than
|
||||
needed: it removes every merge-log row where this client was the
|
||||
survivor. If client X (being deleted) previously absorbed clients
|
||||
A/B/C, the audit trail of those merges is lost on X's deletion. The
|
||||
surviving rows that remain (`mergedClientId = X`) are now
|
||||
inconsistent — they reference a survivor that no longer exists.
|
||||
- **Fix:** Either preserve the survivor rows by setting
|
||||
`surviving_client_id = NULL` (requires column nullable) or keep the
|
||||
current behavior but document it more visibly. At minimum, log the
|
||||
deleted merge-log row count so operators can investigate gaps.
|
||||
|
||||
### M2. Documenso void worker has no max-retry guard for non-404 errors
|
||||
|
||||
- **File:** `src/lib/queue/workers/documents.ts:19-37`
|
||||
- **Scenario:** `voidDocument` throws `CodedError` on non-404 failures
|
||||
(auth error, network blip, Documenso 500). BullMQ retries with
|
||||
backoff, but there's no per-job idempotency check — the second
|
||||
retry hits the same envelope, voidDocument's 404 short-circuit only
|
||||
kicks in if Documenso has actually voided it on the first retry
|
||||
before the API call returned an error. A persistent 401 / 403 will
|
||||
retry forever (until BullMQ exhausts attempts) and the documents row
|
||||
stays `cancelled` in the CRM with the envelope still live in
|
||||
Documenso. The DLQ is mentioned in the comment but the worker
|
||||
doesn't surface a DLQ alert hook.
|
||||
- **Fix:** On exhaustion, write back to `documents` (e.g.
|
||||
`cancellation_failed=true`) and emit an admin notification so the
|
||||
envelope can be voided manually.
|
||||
|
||||
### M3. Next-in-line notification fan-out unhandled rejection
|
||||
|
||||
- **File:** `src/lib/services/next-in-line-notify.service.ts:75-87`
|
||||
- **Scenario:** Each `void createNotification(...)` is a fire-and-forget
|
||||
promise with no `.catch` handler. If `notifications.service`
|
||||
dispatches to a DB that's transiently down, the unhandled rejection
|
||||
will surface in the Node process with no recipient context (the
|
||||
closure captured `userId` is in the stack but pino won't include it
|
||||
unless explicitly logged). Process-level handlers will log it but
|
||||
individual recipients silently lose their notification.
|
||||
- **Fix:** `.catch((err) => logger.warn({err, userId, berthId:
|
||||
input.berthId}, 'next-in-line notification failed'))`.
|
||||
|
||||
### M4. Restore service uses `any` for transaction type
|
||||
|
||||
- **File:** `src/lib/services/client-restore.service.ts:354-355`
|
||||
- **Scenario:** `applyReversal(tx: any, ...)` defeats Drizzle's type
|
||||
safety. A future schema rename (e.g. `yachts.status` enum change)
|
||||
won't fail at compile time inside this function. Combined with the
|
||||
documented v1 no-op for `berth_released`, the function looks
|
||||
innocuous but carries the most risk.
|
||||
- **Fix:** Use the proper Drizzle tx type — `Parameters<Parameters<typeof
|
||||
db.transaction>[0]>[0]` or a named type alias from
|
||||
`@/lib/db/types.ts` if one exists.
|
||||
|
||||
### M5. interests.changeInterestStage milestones write outside tx
|
||||
|
||||
- **File:** `src/lib/services/interests.service.ts:630-648`
|
||||
- **Scenario:** The override path (and normal path) writes
|
||||
`pipelineStage` in one update and milestone fields
|
||||
(`dateEoiSent`, `dateContractSigned`, etc.) in a second update. If
|
||||
the process crashes between the two, the stage advances but the
|
||||
milestone is never recorded. Funnel/conversion math then under-
|
||||
counts that interest. Over-the-wire this is rare but the audit log
|
||||
fires before the milestone update succeeds, so the audit trail
|
||||
claims a complete transition that's actually half-applied.
|
||||
- **Fix:** Combine both into a single update statement, computing the
|
||||
milestone fields in JS and merging them into the `set({...})` clause.
|
||||
|
||||
---
|
||||
|
||||
## Low
|
||||
|
||||
### L1. Smart-archive coalesces invoice notes via SQL string concat
|
||||
|
||||
- **File:** `src/lib/services/client-archive.service.ts:288-291`
|
||||
- **Scenario:** `notes: sql\`coalesce(${invoices.notes}, '') || ${...}\``embeds`new Date().toISOString()`and the action label inside a
|
||||
parameterized string. The values are bound, so it's not an injection
|
||||
risk, but the`\n[archive ...]` marker is appended unconditionally —
|
||||
re-running the archive on a not-yet-committed client would double
|
||||
the marker. Combined with H5 (no idempotency on bulk), a retry could
|
||||
bloat invoice notes with duplicate markers.
|
||||
- **Fix:** Append only when the marker isn't already present, or rely
|
||||
on the `clients.archivedAt is null` precheck (which already guards
|
||||
re-entry) and accept the duplicate as theoretically impossible.
|
||||
|
||||
### L2. Hard-delete `requestHardDeleteCode` reveals client existence pre-archive
|
||||
|
||||
- **File:** `src/lib/services/client-hard-delete.service.ts:77-85`
|
||||
- **Scenario:** A user without `admin.permanently_delete_clients`
|
||||
shouldn't reach this service, so this is theoretical, but the
|
||||
ConflictError "Client must be archived" leaks the existence of an
|
||||
unarchived client to anyone who can reach the route. The audit doc
|
||||
flagged hard-delete error messages already (out of scope), but this
|
||||
specific error path isn't covered there.
|
||||
- **Fix:** Same as the audit-doc finding for the symmetric path —
|
||||
return a generic `NotFoundError` instead of distinguishing
|
||||
"not found" from "not archived" externally; log the distinction
|
||||
internally only.
|
||||
147
docs/berth-feature-handoff-prompt.md
Normal file
147
docs/berth-feature-handoff-prompt.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Handoff prompt for new Claude Code session
|
||||
|
||||
Copy everything below the `---` line into the new chat as your first message.
|
||||
|
||||
---
|
||||
|
||||
I'm continuing work on a comprehensive multi-feature push that was fully designed in a prior session but not yet implemented. The complete plan lives at `docs/berth-recommender-and-pdf-plan.md` (~1030 lines). **Read that file end-to-end before doing anything else — every design decision, schema change, edge case, and confirmed answer to a product question is captured there.** Don't re-litigate decisions; if something seems unclear, the answer is almost certainly in the plan.
|
||||
|
||||
## What the project is
|
||||
|
||||
A multi-tenant marina/port-management CRM at `/Users/matt/Repos/new-pn-crm`. Next.js 15 App Router, React 19, TypeScript strict, Drizzle ORM on Postgres, MinIO for files, BullMQ on Redis, better-auth, shadcn/ui, Tailwind. See `CLAUDE.md` for the conventions.
|
||||
|
||||
## What we're building (high level)
|
||||
|
||||
The plan bundles 8 capabilities into one branch (`feat/berth-recommender`):
|
||||
|
||||
1. **/clients + /interests list-column fix** (the original bug — list views show `-` everywhere because the service didn't join contacts/yachts)
|
||||
2. **Full NocoDB Berths import** + seeding + mooring-number normalization (current CRM has `A-01..E-18`; canonical is `A1..E18`)
|
||||
3. **Schema refactor** to many-to-many `interest_berths` with role flags (`is_primary`, `is_specific_interest`, `is_in_eoi_bundle`)
|
||||
4. **Berth recommender** (SQL ranking, tier ladder, heat scoring, UI panel) — no AI; pure SQL
|
||||
5. **EOI bundle** support (multi-berth EOIs + range formatter for the Documenso PDF: `["A1","A2","A3","B5","B6"]` → `"A1-A3, B5-B6"`)
|
||||
6. **Pluggable storage backend** (s3-compatible OR local filesystem) so admins can run without MinIO if they want
|
||||
7. **Per-berth PDFs** (versioned uploads, OCR-based reverse parser, conflict-resolution diff dialog)
|
||||
8. **Sales send-out emails** (berth PDF + brochure) with full audit + size-aware fallback to download links
|
||||
|
||||
## Phase ordering (from plan §2)
|
||||
|
||||
```
|
||||
Phase 0: Full NocoDB berth import + mooring normalization + 5 new pricing columns
|
||||
Phase 1: /clients + /interests list column fix
|
||||
Phase 2: M:M interest_berths schema refactor + desired dimensions on interests
|
||||
Phase 3: CRM /api/public/berths endpoint + website cutover
|
||||
Phase 4: Recommender SQL + tier ladder + heat + UI panel
|
||||
Phase 5: EOI bundle + range formatter
|
||||
Phase 6a: Pluggable storage backend + migration CLI + admin UI
|
||||
Phase 6b: Per-berth PDF storage (versioned) + reverse parser
|
||||
Phase 7: Sales send-outs + brochure admin + email-from settings
|
||||
Phase 8: CLAUDE.md updates + final validation
|
||||
```
|
||||
|
||||
**Start with Phase 0**.
|
||||
|
||||
## Working tree state at handoff
|
||||
|
||||
- Branch: `main` (you'll create `feat/berth-recommender` from here)
|
||||
- Recent commits (already pushed):
|
||||
- `8699f81 chore(style): codebase em-dash sweep + minor layout polish`
|
||||
- `d62822c fix(migration): NocoDB import safety + dedup helpers + lead-source backfill`
|
||||
- `089f4a6 feat(receipts): upload guide page + scanner head-tag fix`
|
||||
- `77ad10c feat(dashboard): custom date range + KPI port-hydration gate`
|
||||
- `e598cc0 feat(layout): unified Inbox + UserMenu extraction`
|
||||
- `f5772ce feat(analytics): Umami integration with per-port admin settings`
|
||||
- `49d34e0 feat(website-intake): dual-write endpoint + migration chain repair`
|
||||
- Untracked / uncommitted at handoff:
|
||||
- `docs/berth-recommender-and-pdf-plan.md` (the plan — read this first)
|
||||
- `docs/berth-feature-handoff-prompt.md` (this file)
|
||||
- `berth_pdf_example/` (two reference files — see below)
|
||||
- `.env.example` (modified — adds `WEBSITE_INTAKE_SECRET=`; pre-commit hook blocks `.env*` files so user adds this manually)
|
||||
- Dev DB state:
|
||||
- 245 clients (210 with no `nationality_iso` — Phase 1 backfills from primary phone's `value_country`)
|
||||
- 4 test rows in `website_submissions` (from a previous live audit; safe to ignore)
|
||||
- 90 berths with `mooring_number` in `A-01` format (Phase 0 normalizes to `A1`)
|
||||
- vitest: 956 tests passing
|
||||
- tsc: clean (one pre-existing issue in `scripts/smoke-test-redirect.ts` that's unrelated)
|
||||
|
||||
## Reference files
|
||||
|
||||
- `berth_pdf_example/Berth_Spec_Sheet_A1.pdf` (358 KB) — sample per-berth PDF. **0 AcroForm fields** (confirmed via pdf-lib) so OCR with positional heuristics is the primary parser tier; the AcroForm tier is built defensively. Plan §9.2 captures the layout structure.
|
||||
- `berth_pdf_example/Port-Nimara-Brochure-March-2025_5nT92g.pdf` (10.26 MB) — sample brochure. Sized so it ships as an attachment under the 15 MB threshold. Plan §11.1 covers brochure handling.
|
||||
|
||||
## NocoDB access
|
||||
|
||||
You have `mcp__NocoDB_Base_-_Port_Nimara__*` tools available. Tables you'll touch most:
|
||||
|
||||
- `mczgos9hr3oa9qc` — Berths (Phase 0 imports from here; mooring numbers are stored as `A1..E18`)
|
||||
- `mbs9hjauug4eseo` — Interests (the combined client+deal table the old system used)
|
||||
|
||||
## Branch & commit conventions
|
||||
|
||||
- Create the branch: `git checkout -b feat/berth-recommender`
|
||||
- Commit messages match recent history style: `<type>(<scope>): <subject>` lowercase, terse subject, body explains why not what.
|
||||
- **Pre-commit hook blocks any `.env*` file** including `.env.example`. If you need to update `.env.example`, leave it staged and tell the user to commit manually with `--no-verify` (they're aware of this).
|
||||
- **Don't push without explicit user permission.** Commits are fine; pushes need approval.
|
||||
- **Don't run `git rebase`, `git push --force`, or anything destructive without checking.** The branch is solo-owned but the repo's `main` is shared.
|
||||
|
||||
## User communication preferences (from prior session)
|
||||
|
||||
- Direct, no fluff. If something is a bad idea, say so — don't sycophant.
|
||||
- When proposing changes, include trade-offs explicitly.
|
||||
- For multi-question decisions, use `AskUserQuestion` rather than long bulleted lists.
|
||||
- Run validation (vitest + tsc) at logical checkpoints. Don't ship a commit with regressions.
|
||||
- The user prefers small focused commits over mega-commits. Within Phase 0 alone there will probably be 2-3 commits (e.g. mooring normalization, schema additions, NocoDB import script).
|
||||
|
||||
## Critical rules (from plan §14)
|
||||
|
||||
Eleven 🔴 critical items requiring tests before their phase ships:
|
||||
|
||||
1. NocoDB mooring collisions → unique constraint + ON CONFLICT
|
||||
2. Non-PDF disguised upload → magic-byte check
|
||||
3. Recipient email typos → pre-send confirmation
|
||||
4. XSS in email body markdown → DOMPurify + payload tests
|
||||
5. SMTP credentials silently failing → loud error + failed `document_sends` row
|
||||
6. Wrong-environment `CRM_PUBLIC_URL` → health-check env match
|
||||
7. Mooring format drift breaking `/berths/A1` URLs → Phase 0 normalization gates Phase 3
|
||||
8. Multi-port isolation in recommender → explicit `port_id` filter + cross-port test
|
||||
9. Permission escalation on SMTP creds → per-port admin only, no rep visibility
|
||||
10. Filesystem backend in multi-node deployment → refuse to start; documented + health-check enforced
|
||||
11. Path traversal via storage key in filesystem mode → strict regex validation + path realpath check
|
||||
|
||||
## Pending items (from plan §9)
|
||||
|
||||
These are non-blocking but worth knowing:
|
||||
|
||||
- Sample brochure already provided (the 10.26 MB file above).
|
||||
- SMTP app password for `sales@portnimara.com` — not yet obtained; expected close to production cutover. Phase 7 ships the admin UI immediately and the credential gets entered when available.
|
||||
- `CRM_PUBLIC_URL` confirmed as `https://crm.portnimara.com` once live; configurable via env.
|
||||
- GDPR cascade behavior for `document_sends` (delete vs. anonymize-PII vs. keep) — left `OPEN` in §14.10, default lean: anonymize-PII. Revisit when Phase 7 schema lands.
|
||||
|
||||
## Scope reminder
|
||||
|
||||
- **No prod data depends on the current CRM schema** — refactors don't need backwards-compatibility shims. But every schema change still ships as a Drizzle migration with `pnpm db:generate`.
|
||||
- **Pluggable storage** rejects Postgres `bytea` as an option (§4.7a). The two backends are s3-compatible (MinIO/AWS/B2/R2/etc.) and local filesystem. Filesystem is single-node only.
|
||||
|
||||
## What to do first
|
||||
|
||||
1. Read `docs/berth-recommender-and-pdf-plan.md` end-to-end. Don't skim. The edge-case audit in §14 alone is critical context.
|
||||
2. Confirm you've understood the plan by stating back the 8-phase outline and the 11 critical items, then ask the user if they want to proceed with Phase 0.
|
||||
3. Once approved, create `feat/berth-recommender` and start Phase 0.
|
||||
|
||||
Phase 0 deliverables (per plan):
|
||||
|
||||
- One commit normalizing existing CRM mooring numbers from `A-01` → `A1` form (via `regexp_replace` migration). Delete the offending `scripts/load-berths-to-port-nimara.ts`.
|
||||
- One commit adding the 5 new berth columns (`weekly_rate_high_usd`, `weekly_rate_low_usd`, `daily_rate_high_usd`, `daily_rate_low_usd`, `pricing_valid_until`, `last_imported_at`). Run `pnpm db:generate`. Verify `meta/_journal.json` prevId chain stays contiguous.
|
||||
- One commit adding `scripts/import-berths-from-nocodb.ts` — the idempotent NocoDB import (handles updates, preserves CRM-side edits via `last_imported_at vs updated_at` check, `pg_advisory_lock`, dry-run flag, etc. per §4.1 and §14.1).
|
||||
- Update `src/lib/db/seed-data.ts` with the imported berth set so fresh installs get them.
|
||||
- Final vitest + tsc validation at the end of Phase 0.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't push to remote during this session (user will batch the push later).
|
||||
- Don't commit `.env*` files (hook blocks them anyway).
|
||||
- Don't edit `.gitignore` to exclude generated artifacts; the repo's existing ignores are correct.
|
||||
- Don't add documentation files unless the plan asks for them — the plan itself is the doc.
|
||||
- Don't add features not in the plan. If something seems missing, ask.
|
||||
- Don't use AI for the recommender (plan §1 + §13). Pure SQL ranking.
|
||||
|
||||
Once you've read the plan and confirmed understanding, ask me whether to proceed with Phase 0.
|
||||
1086
docs/berth-recommender-and-pdf-plan.md
Normal file
1086
docs/berth-recommender-and-pdf-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
722
docs/documenso-build-plan.md
Normal file
722
docs/documenso-build-plan.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# Documenso signing-flow build plan
|
||||
|
||||
Captures every Documenso-related piece that isn't shipped yet, in attack order. A fresh session should be able to pick this up without re-reading the whole conversation.
|
||||
|
||||
**Companion docs:**
|
||||
|
||||
- [docs/documenso-integration-audit.md](./documenso-integration-audit.md) — what's already built, v1/v2 endpoint mapping, nginx CORS block
|
||||
- Old system reference: [client-portal/server/api/eoi/generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [client-portal/server/api/webhooks/documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [client-portal/server/services/documenso-notifications.ts](../client-portal/server/services/documenso-notifications.ts), [Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)
|
||||
|
||||
---
|
||||
|
||||
## Locked design decisions (from user, do NOT re-ask)
|
||||
|
||||
| Q | Decision |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Embedded signing host | `portnimara.com/sign/<role>/<token>` (marketing website hosts the embed page; CRM emits URLs in this format) |
|
||||
| Initial "please sign" email | **Per-port admin setting** `eoi_send_mode`: `auto` = send branded email immediately on generate; `manual` = generate + show URL + Send button |
|
||||
| Contract / Reservation generation | **Upload-and-place-fields per deal only.** EOI is the only template-driven flow. (Resolved Q6 — template-fallback dropped.) |
|
||||
| Reminder cadence | **Manual by default.** Rep clicks "Send reminder" button. Per-doc opt-in for auto-reminders at upload time. (Resolved Q1) |
|
||||
| Document expiration | **Never expire.** No `expiresAt` UI in v1. (Resolved Q2) |
|
||||
| Approver vs CC | **Two concepts**: `APPROVER` = real Documenso recipient that gates signing; `Completion CC` = passive recipient that only receives the signed PDF. (Resolved Q4) |
|
||||
| Witness | **First-class signer role.** Configurable per-document; full reminder/tracking flow. (Resolved Q7) |
|
||||
| Per-port developer label | **Configurable** via `documenso_developer_label` / `documenso_approver_label`. (Resolved Q8 bonus) |
|
||||
| Multi-port template config | All Documenso settings are per-port via `/[portSlug]/admin/documenso` (already wired) |
|
||||
| Documenso API version | Both v1 + v2 supported. Per-port config picks. v1 is prod (1.32) — primary. v2 unlocks embed + envelope |
|
||||
| nginx CORS | User applies manually. Block is in [docs/documenso-integration-audit.md](./documenso-integration-audit.md). Supports multi-origin via `set $cors_origin` regex |
|
||||
| Signer override | **Hybrid** — template docs (EOI) keep template-fixed signers (per-port settings fill the slots). Custom-uploaded docs (contract, reservation) get full per-deal signer customization. |
|
||||
| Multi-berth | EOI keeps existing bundle support. Contract/reservation are custom-uploaded PDFs — no PDF form-fill, just Documenso signature/initials/date fields |
|
||||
| Test mode | Reuse `EMAIL_REDIRECT_TO` env var (already redirects every outbound email + Documenso recipient) |
|
||||
| Regenerate handling | Match old system: 3 retries to delete prior Documenso doc with 2-second wait. **Plus** a confirm modal: "Retain old EOI? (default no)" |
|
||||
| Field placement strategy | **Auto-detect (anchor text scanner) + manual drag-drop UI as safety net.** Auto-detect populates the initial state; rep can drag/delete/reassign before sending. |
|
||||
|
||||
---
|
||||
|
||||
## What's already shipped (foundation)
|
||||
|
||||
Files in place; do NOT rebuild:
|
||||
|
||||
- `src/lib/services/port-config.ts` — extended with: `documenso_developer_name/email`, `documenso_approver_name/email`, `eoi_send_mode`, `embedded_signing_host`, `documenso_contract_template_id`, `documenso_reservation_template_id`
|
||||
- `src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx` — admin UI exposes every Documenso knob across 5 cards
|
||||
- `src/lib/email/templates/document-signing.ts` — `signingInvitationEmail`, `signingCompletedEmail`, `signingReminderEmail` with per-port branding
|
||||
- `src/lib/services/document-signing-emails.service.ts` — `sendSigningInvitation`, `sendSigningReminder`, `sendSigningCompleted`. Includes `transformSigningUrl(rawUrl, host, role)` for embed URL wrapping
|
||||
- `src/lib/services/documenso-client.ts` — extended `DocumensoFieldType` to all 11 types: SIGNATURE, FREE_SIGNATURE, INITIALS, DATE, EMAIL, NAME, TEXT, NUMBER, CHECKBOX, DROPDOWN, RADIO. Plus typed `DocumensoTextFieldMeta`/`NumberFieldMeta`/`ChoiceFieldMeta` interfaces and `fieldTypeNeedsMeta(type)` helper
|
||||
- `src/components/interests/interest-eoi-tab.tsx` — EOI workspace with active-doc hero, signing progress, paper-signed upload, history strip
|
||||
- `src/components/interests/interest-contract-tab.tsx` — Contract workspace shell with paper-signed upload + "send for signing" placeholder dialog
|
||||
- `src/components/interests/interest-reservation-tab.tsx` — Reservation workspace shell (clone of Contract)
|
||||
- `src/components/interests/interest-tabs.tsx` — stage-conditional visibility wired
|
||||
|
||||
What works today end-to-end: generate EOI → Documenso template path → manual link sharing (rep copies URL out of UI). What does NOT yet work: auto-send branded invitation, cascading "your turn" emails, custom-doc upload-to-Documenso, embedded signing URL emission to the website, on-completion PDF distribution.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — EOI generate flow polish (~3 hours)
|
||||
|
||||
> **Updated for Q1, Q4, Q6, Q8 resolutions.** Adds manual-reminder endpoint, two new per-port label settings, drop of contract/reservation template settings, schema columns for completion CCs + auto-reminder. Also folds in webhook-secret hardening (Risk #7 Option A) and `transformSigningUrl` role mapping (Risk #5 fix).
|
||||
|
||||
**Why first**: Smallest surface area, validates the per-port `eoi_send_mode` setting works end-to-end, gets the cascading-email mental model in place before tackling the bigger pieces.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. **Auto-send wiring**: in `src/components/documents/eoi-generate-dialog.tsx`, after `handleGenerate()` succeeds:
|
||||
- Fetch port's `eoi_send_mode` (already on `getPortDocumensoConfig(portId)`)
|
||||
- If `auto`: server-side already sent the doc to Documenso with `sendEmail: false`. Now call new endpoint `POST /api/v1/documents/[id]/send-invitation` (build it) which:
|
||||
- Looks up the document's signers
|
||||
- Calls `sendSigningInvitation()` for the first signer (the client; signing order 1)
|
||||
- Stores `sent_at` timestamp on the signer row
|
||||
- If `manual`: do nothing. Surface the signing URL in the EOI tab + a "Send invitation" button that hits the same endpoint.
|
||||
|
||||
2. **Regenerate confirm modal**: when EOI tab's "Generate EOI" button is clicked AND a Documenso doc already exists for this interest (`activeDoc !== null`):
|
||||
- Show a `<Dialog>` asking: "There's already an EOI in flight. Regenerating will create a new document and the existing one will be cancelled."
|
||||
- Two buttons: "Cancel" (default), "Regenerate" (destructive)
|
||||
- Below the buttons, a checkbox: "Keep the previous EOI in Documenso (don't delete)" — defaults UNCHECKED
|
||||
- On confirm: if checkbox unchecked, call `voidDocument(oldId, portId)` with 3 retries + 2-second wait between (mirror old system's `generate-quick-eoi.ts` lines 110-162). Then run the normal generate flow.
|
||||
|
||||
3. **Send-invitation endpoint**: new file `src/app/api/v1/documents/[id]/send-invitation/route.ts`:
|
||||
|
||||
```ts
|
||||
POST /api/v1/documents/[id]/send-invitation
|
||||
Body: { recipientId?: string } // optional — defaults to first unsigned recipient
|
||||
```
|
||||
|
||||
- Loads the document + signers
|
||||
- Resolves the target recipient (passed-in or first unsigned in signing order)
|
||||
- Resolves port's documenso config + the recipient's signing URL from the document_signers row
|
||||
- Calls `sendSigningInvitation` from the email service
|
||||
- Updates `document_signers.invited_at` (need to add column — see schema migration below)
|
||||
|
||||
4. **Schema migration**: add `invited_at` and `last_reminder_sent_at` columns to `document_signers`:
|
||||
```sql
|
||||
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
|
||||
```
|
||||
The webhook handler updates these (Phase 2). Apply via psql then restart dev server (per CLAUDE.md migration note).
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Setting `eoi_send_mode=auto` in admin → generating an EOI fires off our branded HTML email to the client immediately
|
||||
- Setting `eoi_send_mode=manual` → no email fires; "Send invitation" button in EOI tab hits the endpoint
|
||||
- Clicking Generate when an active EOI exists → confirm dialog with checkbox; default deletes prior doc with retries
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Webhook handler enhancement (~3-4 hours)
|
||||
|
||||
**Why second**: Once invitations are flowing (Phase 1), the webhook needs to track the lifecycle and fire the cascading "your turn" emails as each signer completes. Without this, the system goes silent after the initial invite.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. **Extend `src/app/api/webhooks/documenso/route.ts`** to handle `DOCUMENT_OPENED`, `DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED` (DOCUMENT_OPENED currently ignored).
|
||||
|
||||
2. **For `DOCUMENT_SIGNED`** (fires when one recipient signs, can fire multiple times per doc):
|
||||
- Resolve the (port, document, signer) — existing per-port secret lookup already does this
|
||||
- Update `document_signers.signed_at` for the matching signer
|
||||
- Find the next unsigned signer in signing order
|
||||
- If next signer exists AND we haven't already invited them: call `sendSigningInvitation()` with the next signer + their signing URL + role='developer' (or 'approver' depending on signing order). Mark `document_signers.invited_at` for them.
|
||||
- This is the cascading "your turn" flow that mirrors `client-portal/server/services/documenso-notifications.ts`
|
||||
|
||||
3. **For `DOCUMENT_OPENED`**:
|
||||
- Update `document_signers.opened_at` for the matching recipient (matched by token in payload)
|
||||
- Used for analytics later ("12% of clients open within an hour")
|
||||
|
||||
4. **For `DOCUMENT_COMPLETED`** (fires once when all signers have signed):
|
||||
- Update document `status='completed'`, `completed_at=...`
|
||||
- Download signed PDF: `await downloadSignedPdf(documensoId, portId)` (existing)
|
||||
- Store in storage backend via the file ingestion flow — this creates a `files` row
|
||||
- Update the document row to point at the signed file (`signed_file_id`)
|
||||
- Call `sendSigningCompleted()` with all signers + the signed file's id
|
||||
- Update the linked interest's pipeline stage:
|
||||
- If document type = `eoi` → `eoi_signed`
|
||||
- If document type = `contract` → `contract_signed`
|
||||
- If document type = `reservation_agreement` → leave stage; reservation is post-deal-close anyway
|
||||
|
||||
5. **Recipient-token matching**: webhooks include `payload.recipients[]` with each recipient's `token`. Use the token to match against `document_signers.signing_token` (need to add the column if not already). Old system's webhook does this via email match — fragile when the same email serves multiple roles. Token match is robust.
|
||||
|
||||
6. **Idempotency**: webhook can fire duplicates. Old system's `acquireWebhookLock` + signature comparison pattern is good. Port that logic.
|
||||
|
||||
### Schema migration
|
||||
|
||||
```sql
|
||||
-- Add fine-grained tracking columns to document_signers
|
||||
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN signing_token text; -- index this
|
||||
|
||||
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);
|
||||
```
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Client signs → developer receives our branded "your turn" email within seconds
|
||||
- Developer signs → approver receives the same
|
||||
- All signed → all three recipients receive the signed PDF as attachment
|
||||
- Interest's pipeline stage advances to `eoi_signed` automatically
|
||||
- Re-firing of duplicate webhooks is no-op
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Custom document upload-to-Documenso (~6-8 hours)
|
||||
|
||||
**Why third**: Backend foundation for contract + reservation flows. Without this, the "Upload draft for signing" CTA on those tabs is a placeholder.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. **New service** `src/lib/services/custom-document-upload.service.ts`:
|
||||
|
||||
```ts
|
||||
export async function uploadDocumentForSigning(args: {
|
||||
interestId: string;
|
||||
portId: string;
|
||||
documentType: 'contract' | 'reservation_agreement';
|
||||
pdfBuffer: Buffer;
|
||||
filename: string;
|
||||
title: string;
|
||||
recipients: Array<{
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'SIGNER' | 'APPROVER' | 'CC';
|
||||
signingOrder: number;
|
||||
}>;
|
||||
fields: DocumensoFieldPlacement[]; // from auto-detect or manual placement
|
||||
}): Promise<{ documentId: string; signingUrls: Record<string, string> }>;
|
||||
```
|
||||
|
||||
Steps:
|
||||
- Convert pdfBuffer → base64
|
||||
- Call `createDocument(title, base64, recipients, portId)` — existing client function
|
||||
- Call `placeFields(docId, fields, portId)` — existing client function (handles v1 + v2)
|
||||
- Call `sendDocument(docId, portId)` — existing
|
||||
- Return doc ID + per-recipient signing URLs
|
||||
- Mirror the timing-safe URL extraction from old system's generate-quick-eoi (recipients[].signingUrl)
|
||||
- Insert a row into our `documents` table with the new doc_id + signers + interest link
|
||||
- If port's `eoi_send_mode === 'auto'`: kick off `sendSigningInvitation()` to first signer
|
||||
|
||||
2. **API endpoint**: `POST /api/v1/interests/[id]/upload-for-signing`
|
||||
- Accepts multipart: `file` (the PDF), `documentType`, `title`, `recipients` (JSON), `fields` (JSON)
|
||||
- Validates: file is PDF (magic-byte check, see berth-pdf flow), recipients ≥ 1, fields ≥ 1
|
||||
- Calls service
|
||||
- Returns 201 with the new document row
|
||||
|
||||
3. **Update Contract + Reservation tab placeholders** to open a real upload dialog (see Phase 4).
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Endpoint accepts a PDF + recipients + fields and returns a Documenso doc ID
|
||||
- Document appears in the Documents tab with status `sent`
|
||||
- v1 and v2 paths both work (same code path; client chooses based on per-port config)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Recipient configurator + Field placement UI (~10-14 hours)
|
||||
|
||||
**Why fourth**: This is the BIG visual piece. Don't start until Phase 3 backend is proven via curl.
|
||||
|
||||
### Sub-phase 4a: Recipient configurator (~2-3 hours)
|
||||
|
||||
UI inside a new `<UploadForSigningDialog>` component:
|
||||
|
||||
- File picker (drag-drop + click)
|
||||
- Title input (defaults to filename minus extension)
|
||||
- Recipients list:
|
||||
- Add row → name + email + role (SIGNER/APPROVER/CC) + signing order (number, auto-increments)
|
||||
- Drag to reorder (uses `dnd-kit`, already in deps)
|
||||
- Delete row
|
||||
- Defaults: client (signing order 1) prefilled from interest's linked client; developer + approver prefilled from port settings
|
||||
- "Configure fields →" button advances to sub-phase 4b
|
||||
|
||||
### Sub-phase 4b: PDF rendering (~3-4 hours)
|
||||
|
||||
- Install: `pnpm add react-pdf` (uses pdfjs-dist under the hood; pdfme already pulls pdfjs-dist so no new dep weight)
|
||||
- Render the uploaded PDF page-by-page using `<Document>` + `<Page>` from react-pdf
|
||||
- Page navigation (prev/next, page picker)
|
||||
- Zoom controls (50%, 75%, 100%, 125%, 150%)
|
||||
|
||||
### Sub-phase 4c: Auto-detect scanner (~4-6 hours)
|
||||
|
||||
New file `src/lib/services/document-field-detector.ts`:
|
||||
|
||||
```ts
|
||||
export interface DetectedField {
|
||||
type: DocumensoFieldType;
|
||||
pageNumber: number;
|
||||
pageX: number; // 0-100 percent
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
/** Confidence 0-1 — how sure the scanner is. */
|
||||
confidence: number;
|
||||
/** Original anchor text (for debugging / display). */
|
||||
anchorText?: string;
|
||||
/** Inferred recipient (from nearby labels). null = unassigned. */
|
||||
inferredRecipientLabel?: string | null;
|
||||
}
|
||||
|
||||
export async function detectFields(pdfBuffer: Buffer): Promise<DetectedField[]>;
|
||||
```
|
||||
|
||||
Implementation:
|
||||
|
||||
- Use `pdfjs-dist` to extract text per page with `getTextContent()` — gives `{str, transform: [a,b,c,d,e,f]}` per text item where `e,f` is position in PDF user space, plus `width/height`
|
||||
- Anchor patterns:
|
||||
- `SIGNATURE`: `/signature[:\s_-]+/i`, `/sign\s*here[:\s_-]*/i`, `/X\s*_{4,}/i`, `/signed\s*by[:\s]+/i`
|
||||
- `INITIALS`: `/initials?[:\s_-]+/i`
|
||||
- `DATE`: `/dated?[:\s_-]+/i`, `/date\s+of\s+signature/i`
|
||||
- `NAME`: `/(printed?\s*)?name[:\s_-]+/i`, `/full\s+name[:\s_-]+/i`
|
||||
- `EMAIL`: `/email[:\s_-]+/i`
|
||||
- Catch-all: `/_{8,}/` → if not preceded by name/email/date keyword, default to TEXT
|
||||
- For each match: place field bounding box immediately AFTER the matched text (offset 5pt right), with type-appropriate width:
|
||||
- SIGNATURE: 150pt × 30pt
|
||||
- INITIALS: 50pt × 30pt
|
||||
- DATE: 80pt × 20pt
|
||||
- NAME: 150pt × 20pt
|
||||
- EMAIL: 200pt × 20pt
|
||||
- TEXT: 200pt × 20pt
|
||||
- Convert to PERCENT (divide by page width/height)
|
||||
- Recipient inference: scan ±100pt of the field for labels like "Buyer", "Seller", "Client", "Developer", "Witness", "Notary". Map to recipient by role.
|
||||
|
||||
### Sub-phase 4d: Drag-drop overlay (~3-4 hours)
|
||||
|
||||
- Overlay absolute-positioned divs on top of the PDF viewer for each field
|
||||
- Each field shows: type icon + recipient color + delete (×) handle + drag affordance
|
||||
- Use `dnd-kit` to enable drag — update `pageX/pageY` in state on drop
|
||||
- Field palette toolbar: 11 buttons (one per Documenso field type) — click to enter "place mode" → next click on the PDF places a new field at that coord
|
||||
- Side panel for selected field:
|
||||
- Type changer (dropdown)
|
||||
- Recipient assignment (dropdown of configured recipients)
|
||||
- Required toggle
|
||||
- Per-type config (TEXT label, NUMBER min/max, CHECKBOX/DROPDOWN/RADIO options) — drives `fieldMeta`
|
||||
- Width/height inputs
|
||||
- Delete button
|
||||
|
||||
### Sub-phase 4e: Send (~1 hour)
|
||||
|
||||
"Send for signing" button:
|
||||
|
||||
- Validates: ≥1 recipient, ≥1 field, every field has a recipient assigned
|
||||
- POSTs to `/api/v1/interests/[id]/upload-for-signing` (Phase 3)
|
||||
- On success, closes dialog and refreshes the Contract/Reservation tab
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Upload a draft PDF → auto-detect runs → fields appear overlaid in their detected positions
|
||||
- Rep can drag any field to reposition (state updates, persists to backend on send)
|
||||
- Rep can change a field's type, recipient, or metadata via side panel
|
||||
- Rep can add new fields by clicking palette button + clicking on PDF
|
||||
- Rep can delete fields they don't want
|
||||
- Click Send → fields ship to Documenso, signing flow starts, Contract tab shows the active doc
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Embedded signing URL emission verification (~1-2 hours)
|
||||
|
||||
**Why later**: The Vue page on the marketing website already exists. This phase is a verification + documentation pass, not a code build.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. **Verify URL transformation matches website expectations**:
|
||||
- Website route: `/sign/[type]/[token]` where `type ∈ {client, cc, developer}`
|
||||
- Our `transformSigningUrl()` emits `/sign/<role>/<token>` where role can be `client | developer | approver | witness | other`
|
||||
- Mismatch: website only handles `client | cc | developer`. Our email service may emit `approver` (which the website doesn't route).
|
||||
- **Fix**: either (a) update website's `[type].vue` to accept `approver` (and `witness | other` if needed), OR (b) map our role names to the website's expected names in `transformSigningUrl()`.
|
||||
|
||||
2. **For contract + reservation document types**: the website's `signerMessages` map only covers EOI-specific copy. When a contract goes out for signing and the recipient hits `portnimara.com/sign/client/<token>`, the page would show "Sign Your Expression of Interest" — wrong copy.
|
||||
- **Fix**: add document-type to the URL too: `/sign/<docType>/<role>/<token>`. Update website's signerMessages to be keyed on `(docType, role)`.
|
||||
|
||||
3. **Webhook callback URL**: website POSTs to `client-portal.portnimara.com/api/webhook/document-signed` after signing. The new CRM is at a different domain. Update website's `handleDocumentSigned` to POST to the new CRM's webhook (a thin "client confirmed sign" notification, separate from Documenso's own webhook).
|
||||
|
||||
4. **Apply nginx CORS block** — already documented in [docs/documenso-integration-audit.md](./documenso-integration-audit.md). Apply via ssh when user grants access.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Embedded URL points at a working website page that loads the right Documenso embed for any document type / role combo
|
||||
- Post-sign callback updates our document_signers row (redundant with the Documenso webhook but useful as a real-time UI signal)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Polish & deferred items (~2-3 hours each, do as needed)
|
||||
|
||||
- **`auto` send mode delay**: optional per-port `eoi_send_delay_minutes` setting. When set, the auto-send fires after N minutes (BullMQ scheduled job) so the rep can review + cancel during the window. Default 0 (immediate).
|
||||
- **Audit log entries**: every Documenso-related action (generate, send, remind, cancel, sign-event-received) writes to `audit_logs` with structured metadata. Mostly already there for the existing flow; extend to cover Phase 1-3 additions.
|
||||
- **Per-document customization of email copy**: rep can override the default signing-invitation body before send. New textarea in the upload dialog. Stored as `documents.invitation_message`.
|
||||
- **Document expiration**: Documenso supports `expiresAt`. Surface as a per-document field in the upload dialog.
|
||||
- **Reminder rate-limit display**: surface "next reminder available in X days" on each unsigned signer in the signing-progress UI.
|
||||
- **Failed-webhook recovery UI**: admin page showing webhooks that errored, with a "Replay" button. Old system has the foundation; CRM doesn't.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Project Director role + RBAC layer (~6-8 hours)
|
||||
|
||||
> **Surfaced from Q8 conversation.** The `developer` signer slot is conceptually the "Project Director" — the person at the port who countersigns deals on behalf of the port. Today every CRM user is either a sales rep or admin; there's no Project Director user role. Attack alongside the Documenso build because (a) the Documenso developer-label setting is meaningless if no user actually has the role, and (b) a few permissions naturally cluster around it.
|
||||
|
||||
### What a Project Director needs (vs sales rep)
|
||||
|
||||
| Capability | Sales rep | Project Director | Admin |
|
||||
| -------------------------------------------------------- | --------- | ---------------- | ----------------------------- |
|
||||
| Generate EOI / contract / reservation | ✓ | ✓ | ✓ |
|
||||
| Approve / sign as the "developer" recipient on Documenso | — | ✓ | — (unless also designated PD) |
|
||||
| View own deals | ✓ | ✓ | ✓ |
|
||||
| View other reps' deals | — | ✓ | ✓ |
|
||||
| View audit logs (read-only) | — | ✓ | ✓ |
|
||||
| Trigger CSV / report exports | — | ✓ | ✓ |
|
||||
| Re-assign deals between reps | — | ✓ | ✓ |
|
||||
| Edit per-port settings | — | — | ✓ |
|
||||
| Manage users + invitations | — | — | ✓ |
|
||||
| Manage Documenso config | — | — | ✓ |
|
||||
|
||||
So Project Director sits between sales rep and admin: read-everywhere + a few action capabilities (re-assign, export, sign-as-PD), but no settings/user management.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. **Add `project_director` to the role enum** in `src/lib/db/schema/users.ts` (or wherever port_roles enum lives). Existing role values (sales, admin, super_admin) stay; this is additive.
|
||||
|
||||
2. **Permission flags**: extend the per-port permissions matrix (`src/lib/auth/permissions.ts` or equivalent) with new flags:
|
||||
- `viewAllDeals` — true for project_director, admin, super_admin
|
||||
- `viewAuditLogs` — true for project_director, admin, super_admin
|
||||
- `exportReports` — true for project_director, admin, super_admin
|
||||
- `reassignDeals` — true for project_director, admin, super_admin
|
||||
- `signAsProjectDirector` — true for project_director only (admin can sign as PD only if also assigned the role on this port)
|
||||
|
||||
These flags get checked in the relevant API handlers via the existing `withPermission()` middleware.
|
||||
|
||||
3. **Documenso developer-slot binding**: per-port admin UI gets a "Project Director user" dropdown alongside the existing developer-name/email free-text inputs. When a real CRM user is selected, the admin UI:
|
||||
- Populates `documenso_developer_name/email` from the user's profile (read-only when bound)
|
||||
- When that user signs an EOI/contract via Documenso, the webhook handler can match by user-email and update the in-CRM signing UI in real time (signer chip turns green for them specifically)
|
||||
- Free-text fallback stays for ports without a CRM-PD user yet
|
||||
|
||||
4. **User invitations + role selection**: extend `src/components/admin/invite-user-dialog.tsx` to surface "Project Director" alongside Sales / Admin as a selectable role at invitation time.
|
||||
|
||||
5. **Audit-log access**: surface a new `/[portSlug]/admin/audit-log` route (or extend the existing one's permission gate) so Project Directors can read but not write. Hide write controls for non-admins.
|
||||
|
||||
6. **Reports page permission gate**: existing `/[portSlug]/reports` (or wherever exports live) checks `exportReports` permission flag instead of admin-only.
|
||||
|
||||
7. **Re-assign deals UI**: add a "Re-assign owner" action on the interest detail page, gated by `reassignDeals`. Writes to `interests.owner_user_id` (or whatever the assigned-rep field is) and audit-logs the change.
|
||||
|
||||
### Schema migration
|
||||
|
||||
```sql
|
||||
-- Add project_director as a valid role; depends on how roles are stored.
|
||||
-- If port_roles uses an enum:
|
||||
ALTER TYPE port_role ADD VALUE 'project_director';
|
||||
-- Or if it's a text column with check constraint:
|
||||
ALTER TABLE port_roles DROP CONSTRAINT port_roles_role_check;
|
||||
ALTER TABLE port_roles ADD CONSTRAINT port_roles_role_check
|
||||
CHECK (role IN ('sales', 'admin', 'super_admin', 'project_director'));
|
||||
|
||||
-- Optional: link the per-port Documenso developer slot to a real user
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS user_id text REFERENCES users(id) ON DELETE SET NULL;
|
||||
-- (Used for the documenso_developer_user_id setting; null for free-text fallback)
|
||||
```
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- A user invited as `project_director` can view all deals across the port (not just their own), read audit logs, trigger exports, and re-assign deals — but cannot edit settings or invite users
|
||||
- Admin can bind a CRM user to the per-port Documenso developer slot; the user's name + email auto-populate in invitations and emails
|
||||
- Non-PD users cannot trigger PD-only actions (server returns 403; UI hides the controls)
|
||||
- Existing sales / admin / super_admin permissions are unchanged
|
||||
|
||||
### Why attack at the same time as the Documenso build
|
||||
|
||||
- Both touch `port-config.ts` and `admin/documenso/page.tsx` — fewer rebases if done in one push
|
||||
- The `documenso_developer_label` setting (Q8 bonus) and the PD-user binding overlap; doing them together avoids re-touching the same admin card twice
|
||||
- The Documenso webhook's per-signer matching benefits from having a real `users.email` to bind against, not just a free-text developer name
|
||||
|
||||
### Out of scope (defer to a later RBAC pass)
|
||||
|
||||
- Custom permission templates (e.g. "PD with no audit-log access")
|
||||
- Per-deal ACLs (sharing a single interest with another rep)
|
||||
- Time-bound role grants
|
||||
- Cross-port role overrides for super_admin
|
||||
|
||||
---
|
||||
|
||||
## Risks + decisions (resolved through code review)
|
||||
|
||||
Each entry below was checked against the current code. The original "open question" form is preserved in italics for traceability; the **Decision** is what the next session should implement.
|
||||
|
||||
---
|
||||
|
||||
### 1. `fieldMeta` on Documenso v1.32
|
||||
|
||||
_Q: Does v1.32 silently ignore unknown properties, or does it reject the request?_
|
||||
|
||||
**Decision: not a risk in current code.** [src/lib/services/documenso-client.ts:491-501](../src/lib/services/documenso-client.ts#L491) shows the v1 path constructs its own body containing only `recipientId, type, pageNumber, pageX/Y/Width/Height` — `fieldMeta` is never sent on v1. The code comment at [line 341-344](../src/lib/services/documenso-client.ts#L341) is misleading — update it. Action for next session: change the comment to "v1 does not receive `fieldMeta` (we never send it). v1 renders TEXT/NUMBER/CHECKBOX/DROPDOWN/RADIO as blank inputs; if the per-port admin chose v1 the field UI should warn 'Configurable field types require Documenso v2'." The placement UI in Phase 4d should disable the meta-config side panel when the resolved port is on v1.
|
||||
|
||||
### 2. PDF dimension extraction (non-A4 contracts)
|
||||
|
||||
_Q: How do we get real page dimensions on the v1 path?_
|
||||
|
||||
**Decision: parse the PDF with pdf-lib in the upload service before calling `placeFields()`.** pdf-lib is already a transitive dep via the EOI form-fill flow ([src/lib/pdf/fill-eoi-form.ts](../src/lib/pdf/fill-eoi-form.ts)). Concrete change for Phase 3:
|
||||
|
||||
```ts
|
||||
// In src/lib/services/custom-document-upload.service.ts
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
const pdfDoc = await PDFDocument.load(pdfBuffer);
|
||||
const pageDims = pdfDoc.getPages().map((p) => {
|
||||
const { width, height } = p.getSize();
|
||||
return { width, height };
|
||||
});
|
||||
// Pass to placeFields as a per-page dimension map override
|
||||
```
|
||||
|
||||
Then extend `placeFields` signature to accept an optional `pageDimensionsOverride?: DocumensoPageDimensions[]` (one entry per page). When provided, the v1 path uses `pageDimensionsOverride[fieldPageIndex]` instead of [`getPageDimensions()`'s A4 default](../src/lib/services/documenso-client.ts#L427). Falls back to A4 when override is missing — keeps the EOI template path (which IS A4) unchanged.
|
||||
|
||||
### 3. Multi-page signature blocks not picked up by auto-detect
|
||||
|
||||
_Q: What's the recovery path if the scanner misses a signature block on the last page?_
|
||||
|
||||
**Decision: not a risk — by design.** Phase 4d's drag-drop overlay + field palette is the explicit fallback. Auto-detect populates initial state; rep MUST be able to add fields manually. The acceptance criterion at the end of Phase 4 already covers this. Demoted from "risk" to "design note": every page must be reachable in the PDF viewer (Phase 4b's page navigation) and the field palette must be enabled even on auto-detected pages.
|
||||
|
||||
### 4. Webhook payload differences v1 vs v2
|
||||
|
||||
_Q: Does our webhook handler decode both v1 and v2 payload shapes correctly?_
|
||||
|
||||
**Decision: partially confirmed; finish the audit in Phase 2.** Confirmed working today:
|
||||
|
||||
- Secret transport: identical (`X-Documenso-Secret` plaintext) — see [route.ts:53](../src/app/api/webhooks/documenso/route.ts#L53)
|
||||
- Event names: both versions send the uppercase Prisma enum (`DOCUMENT_SIGNED`); CLAUDE.md note documents this. The route also normalizes lowercase-dotted variants for forward-compat.
|
||||
- Top-level shape `{ event, payload: { id, ... } }`: same on both versions
|
||||
|
||||
Still unverified (defer to Phase 2 implementation):
|
||||
|
||||
- v2 may rename `payload.id` → `payload.documentId` and `recipient.id` → `recipient.recipientId` (mirrors the API-response rename — see [src/lib/services/documenso-client.ts](../src/lib/services/documenso-client.ts) `normalizeDocument()`). Apply the same dual-field read pattern in the webhook handler: `const docId = payload.documentId ?? payload.id`.
|
||||
- v2 may include `payload.envelopeId` instead of `payload.id` for envelope-level events (DOCUMENT_COMPLETED). Read both.
|
||||
- Recipient token field: v1 uses `recipient.token`; v2 may differ. Phase 2's token-based matching (step 5) needs to handle both.
|
||||
|
||||
Test with a v2 instance during Phase 2; until then keep the per-port API version setting on v1 only.
|
||||
|
||||
### 5. `approver` role → `cc` URL mapping
|
||||
|
||||
_Q: How do we keep the website's signing page (which only routes `client | cc | developer`) working when our `SignerRole` includes `approver | witness | other`?_
|
||||
|
||||
**Decision: confirmed bug in current code; fix in Phase 5.** [Website route validation](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue#L175) explicitly redirects to `/sign/error` for any `signerType` not in `['client', 'cc', 'developer']`. Our [transformSigningUrl()](../src/lib/services/document-signing-emails.service.ts#L106) emits `${host}/sign/${signerRole}/${token}` with the raw `SignerRole` value. Today, an `approver` invite would land on `/sign/error`.
|
||||
|
||||
Concrete fix in `transformSigningUrl()`:
|
||||
|
||||
```ts
|
||||
const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer'> = {
|
||||
client: 'client',
|
||||
developer: 'developer',
|
||||
approver: 'cc', // legacy: approver showed as "EmbeddedSignatureLinkCC"
|
||||
witness: 'cc', // route through cc page; copy needs a witness override (Phase 5)
|
||||
other: 'cc',
|
||||
};
|
||||
const urlRole = ROLE_TO_URL_SEGMENT[signerRole];
|
||||
return `${host}/sign/${urlRole}/${token}`;
|
||||
```
|
||||
|
||||
Two follow-ups for Phase 5:
|
||||
|
||||
- Add the mapping above to `transformSigningUrl()` — DO this in Phase 1 already since Phase 1 fires the first invitation email.
|
||||
- Update website's `signerMessages` (currently EOI-specific) to be keyed on `(documentType, signerType)` so contract+reservation invites get the right copy — see Phase 5 task 2.
|
||||
|
||||
### 6. Storage backend for signed PDFs
|
||||
|
||||
_Q: Does the on-completion download in Phase 2 use the pluggable storage backend?_
|
||||
|
||||
**Decision: confirmed — pattern already established, just follow it.** [`getStorageBackend()`](../src/lib/storage/index.ts) is used by 9 services in the codebase (berth-pdf, brochures, expense-pdf, invoices, gdpr-export, reports, document-templates, document-sends, email-compose). The [`documents` schema](../src/lib/db/schema/documents.ts) already has the `signedFileId` column with index `idx_docs_signed_file_id`. Phase 2 step 4 is just: `const buffer = await downloadSignedPdf(docId, portId); const file = await ingestFile({ buffer, portId, ... }); await db.update(documents).set({ signedFileId: file.id })...`. Demoted from "risk" to "implementation note" inside Phase 2.
|
||||
|
||||
### 7. Cross-port webhook secret collision
|
||||
|
||||
_Q: Can two ports happen to share the same webhook secret?_
|
||||
|
||||
**Decision: real risk — fix at write-time, not schema.** [system_settings](../src/lib/db/schema/system.ts#L137) is unique on `(key, port_id)`, so the same key+port combo is enforced unique, but there's no global uniqueness on the _value_. The [webhook handler](../src/app/api/webhooks/documenso/route.ts#L62) iterates all configured secrets and breaks on first match — if two ports paste the same secret, the second port's webhooks get attributed to the first. Three options, in preference order:
|
||||
|
||||
**Option A (recommended): generate, never paste.** Replace the textbox in [admin/documenso/page.tsx](<../src/app/(dashboard)/[portSlug]/admin/documenso/page.tsx>) for `documenso_webhook_secret` with a "Generate secret" button that calls `crypto.randomBytes(32).toString('base64url')` server-side and writes it. Display once, mask after. Collision probability is negligible. Admin still has a "Regenerate" button for rotation.
|
||||
|
||||
**Option B: warn at write.** Keep the textbox but on PUT to the setting, query `system_settings WHERE key='documenso_webhook_secret' AND value=?` and fail with a 409 if any other port has this value. Cheap, defensive, but exposes that a value exists somewhere.
|
||||
|
||||
**Option C: schema-level enforcement.** Add a partial unique index `CREATE UNIQUE INDEX system_settings_documenso_secret_unique ON system_settings (value) WHERE key = 'documenso_webhook_secret'`. Strongest, but requires careful ordering during port-clone or restore-from-backup operations.
|
||||
|
||||
Pick Option A. Add to Phase 1 as a polish item — small change, eliminates the risk class.
|
||||
|
||||
---
|
||||
|
||||
## Open questions — RESOLVED 2026-05-07
|
||||
|
||||
All 10 questions plus the bonus role-label question have user-locked answers. Implementation must follow these decisions; do not re-litigate.
|
||||
|
||||
### Q1. Reminder cadence — RESOLVED
|
||||
|
||||
**Decision**: **Manual reminders by default.** Rep clicks a "Send reminder" button in the EOI/Contract tab. Per-document opt-in: rep can configure auto-reminders on a specific doc at send time (e.g. "remind every 7 days until signed").
|
||||
|
||||
**Implications**:
|
||||
|
||||
- No port-wide reminder schedule setting needed.
|
||||
- Phase 1 / 2: skip the BullMQ scheduled-reminder job for now. Add a `POST /api/v1/documents/[id]/send-reminder` endpoint that calls `sendSigningReminder()` for the next-pending signer. Track `last_reminder_sent_at` to enforce Documenso's 24h rate limit on the UI ("Next reminder available in X").
|
||||
- Phase 4a (upload dialog): add an optional "Auto-reminder schedule" field — None (default) / Every 3d / Every 7d. When set, store on `documents.auto_reminder_interval_days`; a once-daily worker iterates unsigned documents and fires due reminders.
|
||||
|
||||
### Q2. Document expiration — RESOLVED
|
||||
|
||||
**Decision**: **Never expire by default.** No expiration UI in v1. Skip Documenso's `expiresAt` entirely.
|
||||
|
||||
**Reasoning**: link expiration doesn't help the regenerate flow (regen already voids+recreates). Adding the UI is overhead with no immediate user benefit.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 3 `uploadDocumentForSigning`: don't expose `expiresAt`.
|
||||
- Phase 4a recipient configurator: no expiration field.
|
||||
- Phase 6 deferred-items list: drop the "Document expiration" item.
|
||||
|
||||
### Q3. Auto-detect confidence threshold — RESOLVED
|
||||
|
||||
**Decision**: **Default ≥0.8 silent / 0.5–0.8 flagged / <0.5 drop**, with the drag-drop overlay (Phase 4d) as the universal fix mechanism — rep can reposition or delete any auto-placed field.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 4c scanner: emit `DetectedField.confidence`; threshold checks live in the UI layer (Phase 4d) so they're easy to tune.
|
||||
- Phase 4d overlay: flagged fields render with a yellow border + "?" badge; rep can click to confirm-as-correct (clears the badge) or drag/delete.
|
||||
|
||||
### Q4. Approver semantics — RESOLVED
|
||||
|
||||
**Decision**: **TWO concepts, not one.**
|
||||
|
||||
1. **APPROVER** = real Documenso `APPROVER` recipient. Gates signing flow (e.g. client signs → approver approves → developer signs). Configured per-port (existing `documenso_approver_name/email` settings).
|
||||
2. **Completion CC** = passive recipient. Does NOT participate in signing. Receives only the final signed PDF as attachment when the doc completes. Set per-document by the rep at send time.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 3 `uploadDocumentForSigning` recipients: support `role: 'SIGNER' | 'APPROVER' | 'CC'`. CCs are NOT created as Documenso recipients — they're stored on `documents.completion_cc_emails` (text array) and emailed by our own service when DOCUMENT_COMPLETED webhook fires.
|
||||
- Phase 4a recipient configurator: split into two sections:
|
||||
- **Signing recipients**: name + email + role (Signer / Approver) + signing order
|
||||
- **Copy on completion** (CC): just email addresses, comma-separated
|
||||
- Phase 2 step 4 (on-completion email distribution): include `documents.completion_cc_emails` recipients with the signed PDF. Dedup by email (see Q5).
|
||||
- Schema migration: `ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];`
|
||||
|
||||
### Q5. On-completion PDF distribution — RESOLVED
|
||||
|
||||
**Decision**: **All signing recipients + rep who generated + per-deal CC**, deduplicated by email address.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 2 step 4: build the recipient list as union of (a) all `document_signers` for this doc, (b) the user who created the doc (`documents.createdBy` → `users.email`), (c) `documents.completion_cc_emails`. Lowercase + dedupe before calling `sendSigningCompleted`.
|
||||
- Common case (rep IS the approver): one email, not two.
|
||||
- Per-port distribution list (originally proposed) is NOT needed — the per-deal CC field covers it. If a port wants `legal@portnimara.com` on every deal, the rep types it once per doc; if it's truly always-on, add a port-default later (deferred to Phase 6).
|
||||
|
||||
### Q6. `documenso_contract_template_id` / `documenso_reservation_template_id` — RESOLVED
|
||||
|
||||
**Decision**: **DROP both settings. EOI is the only template-driven flow.** Contracts and reservations are custom-uploaded per deal — no template fallback.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Remove `documenso_contract_template_id` and `documenso_reservation_template_id` from `port-config.ts` `SETTING_KEYS` and `PortDocumensoConfig` type.
|
||||
- Remove the corresponding fields from `admin/documenso/page.tsx`. Card title becomes "Templates" with just the EOI template ID field.
|
||||
- Phase 3: contract/reservation tabs go straight into the upload dialog — no `if (templateId) { ... }` branch.
|
||||
- Locked design decisions table at top of this doc: update the "Contract / Reservation generation" row to remove the template-fallback option.
|
||||
|
||||
### Q7. Witness role — RESOLVED
|
||||
|
||||
**Decision**: **First-class. Configurable per-document at generation time.** Witness goes through the full invitation/reminder/tracking flow same as any other signer; signs the document attesting to having witnessed.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Keep `witness` in `SignerRole`.
|
||||
- Phase 4a recipient configurator: "Witness" is a selectable role in the role dropdown (alongside Signer / Approver / CC).
|
||||
- Phase 5 website edit: add witness copy to `signerMessages` map ("Witness this signing of…"). Add `witness` to the validated role list at line 175 of `[type]/[token].vue` — currently `['client', 'cc', 'developer']`, becomes `['client', 'cc', 'developer', 'witness']`.
|
||||
- Risk #5 mapping in `transformSigningUrl()`: `witness → 'witness'` (NOT mapped to `cc`). Update the role-to-URL-segment table accordingly.
|
||||
- Witness gets the same reminder/auto-reminder support as any signer — no special-casing.
|
||||
|
||||
### Q8. Multiple developers/approvers per port — RESOLVED (with rename)
|
||||
|
||||
**Decision**: **Stay single per port** for the standard `developer` and `approver` slots. If a port needs more on a custom doc, the rep adds extra signers via the upload-for-signing dialog (Phase 4a recipient configurator).
|
||||
|
||||
**Plus the bonus**: the per-port "developer" label IS configurable via a new `documenso_developer_label` setting (default: "Developer"). Used in email subjects, signer chips, and signing-progress UI. Backend type-name stays `developer` so no schema churn.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Add `documenso_developer_label` and `documenso_approver_label` to `SETTING_KEYS` + `PortDocumensoConfig`.
|
||||
- Admin UI in `documenso/page.tsx` Signers card: each signer card gets a "Display label" input next to name/email.
|
||||
- Email templates in `document-signing.ts`: read the label from the per-port branding config and use it in copy ("Your Project Director, {{name}}, has signed…").
|
||||
- **Open follow-up (out of scope for Documenso build)**: the user mentioned the project-director user MIGHT need different CRM permissions/access from a sales rep (e.g. exclusive audit-log access, more prominent reports). That's a separate RBAC initiative — note it on the audit backlog and don't action here.
|
||||
|
||||
### Q9. Field placement draft persistence — RESOLVED
|
||||
|
||||
**Decision**: **No persistence.** If the rep closes the dialog mid-placement, state is lost.
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 4 architecture: keep all placement state in React component state. No localStorage, no DB drafts table.
|
||||
- Add a confirm-close on the dialog if the rep has placed any fields ("Discard placement work?").
|
||||
|
||||
### Q10. Embedded signing host fallback — RESOLVED
|
||||
|
||||
**Decision**: **Send raw Documenso URLs** when host is unset. The Documenso API already returns a working signing URL per recipient (e.g. `https://signatures.portnimara.dev/sign/<token>`); `transformSigningUrl()` returns this raw URL untouched when `embeddedSigningHost` is null/empty (current behaviour, see [document-signing-emails.service.ts:106-117](../src/lib/services/document-signing-emails.service.ts#L106)).
|
||||
|
||||
**Implications**:
|
||||
|
||||
- Phase 1: no behaviour change in `transformSigningUrl()`. The current null-host short-circuit IS the fallback.
|
||||
- Add a banner in the EOI/Contract tab when port has unset `embedded_signing_host` and at least one outstanding doc: "Signing emails currently link to signatures.portnimara.dev directly. Configure an embedded host in admin for branded signing pages."
|
||||
- No new env var. No blocking-on-send.
|
||||
|
||||
---
|
||||
|
||||
## Schema migration summary (resolved)
|
||||
|
||||
Combining all resolved decisions, the migrations needed are:
|
||||
|
||||
```sql
|
||||
-- Phase 1 (also covers Phase 2's lifecycle tracking)
|
||||
ALTER TABLE document_signers ADD COLUMN invited_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN opened_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN last_reminder_sent_at timestamptz;
|
||||
ALTER TABLE document_signers ADD COLUMN signing_token text;
|
||||
CREATE INDEX idx_ds_signing_token ON document_signers (signing_token);
|
||||
|
||||
-- Phase 1 / Q4 (completion CCs are per-document)
|
||||
ALTER TABLE documents ADD COLUMN completion_cc_emails text[] DEFAULT '{}'::text[];
|
||||
|
||||
-- Phase 1 / Q1 (auto-reminder opt-in per document)
|
||||
ALTER TABLE documents ADD COLUMN auto_reminder_interval_days integer;
|
||||
```
|
||||
|
||||
## Settings to add / remove (resolved)
|
||||
|
||||
**Add to `SETTING_KEYS` + `PortDocumensoConfig`:**
|
||||
|
||||
- `documenso_developer_label` (text, default "Developer") — Q8 bonus
|
||||
- `documenso_approver_label` (text, default "Approver") — Q8 bonus
|
||||
|
||||
**Remove from `SETTING_KEYS` + `PortDocumensoConfig`:**
|
||||
|
||||
- `documenso_contract_template_id` — Q6
|
||||
- `documenso_reservation_template_id` — Q6
|
||||
|
||||
**Remove from admin UI** (`admin/documenso/page.tsx`):
|
||||
|
||||
- Contract template ID input — Q6
|
||||
- Reservation template ID input — Q6
|
||||
|
||||
**Add to admin UI:**
|
||||
|
||||
- Display-label inputs next to developer + approver name/email pairs — Q8 bonus
|
||||
|
||||
---
|
||||
|
||||
**Status**: Plan is now fully resolved. Phase 1 can start without further clarification.
|
||||
|
||||
---
|
||||
|
||||
## Quick file reference
|
||||
|
||||
**Existing — modify in place:**
|
||||
|
||||
- `src/lib/services/documenso-client.ts` (extend createDocument for v2; add recipient management functions)
|
||||
- `src/lib/services/port-config.ts` (no changes expected)
|
||||
- `src/lib/email/index.ts` (consider: add raw-Buffer attachment option to skip MinIO round-trip for one-off PDFs)
|
||||
- `src/app/api/webhooks/documenso/route.ts` (Phase 2 — major rewrite)
|
||||
- `src/components/interests/interest-contract-tab.tsx` (replace ComingSoonDialog with UploadForSigningDialog in Phase 4)
|
||||
- `src/components/interests/interest-reservation-tab.tsx` (same)
|
||||
- `src/components/documents/eoi-generate-dialog.tsx` (Phase 1 — add regenerate confirm)
|
||||
|
||||
**New files to create:**
|
||||
|
||||
- `src/lib/services/custom-document-upload.service.ts` (Phase 3)
|
||||
- `src/lib/services/document-field-detector.ts` (Phase 4c)
|
||||
- `src/components/documents/upload-for-signing-dialog.tsx` (Phase 4)
|
||||
- `src/components/documents/pdf-field-canvas.tsx` (Phase 4b/4d)
|
||||
- `src/components/documents/recipient-configurator.tsx` (Phase 4a)
|
||||
- `src/components/documents/field-palette-toolbar.tsx` (Phase 4d)
|
||||
- `src/components/documents/field-config-side-panel.tsx` (Phase 4d)
|
||||
- `src/app/api/v1/documents/[id]/send-invitation/route.ts` (Phase 1)
|
||||
- `src/app/api/v1/interests/[id]/upload-for-signing/route.ts` (Phase 3)
|
||||
- DB migrations for `document_signers.invited_at` etc. (Phase 1, Phase 2)
|
||||
252
docs/documenso-integration-audit.md
Normal file
252
docs/documenso-integration-audit.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Documenso integration audit
|
||||
|
||||
Reference for the multi-port Documenso signing pipeline in this CRM. Mirrors the legacy client portal's flow ([generate-quick-eoi.ts](../client-portal/server/api/eoi/generate-quick-eoi.ts), [documeso.ts](../client-portal/server/utils/documeso.ts), [documenso.post.ts](../client-portal/server/api/webhooks/documenso.post.ts), [website /sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) but rewired for multi-tenant + better-auth + Drizzle.
|
||||
|
||||
---
|
||||
|
||||
## Per-port configuration
|
||||
|
||||
All Documenso settings live in `system_settings` keyed by `(key, port_id)` and are read via [`getPortDocumensoConfig(portId)`](../src/lib/services/port-config.ts). Falls back to env vars when no per-port row exists. Surfaced in the admin UI at `/[portSlug]/admin/documenso`.
|
||||
|
||||
| Setting key | Type | Purpose |
|
||||
| ----------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `documenso_api_url_override` | string | Per-port Documenso instance URL. Falls back to `DOCUMENSO_API_URL` env. |
|
||||
| `documenso_api_key_override` | string | API key. Stored plaintext. |
|
||||
| `documenso_api_version_override` | `'v1' \| 'v2'` | Different ports may run different Documenso versions. |
|
||||
| `documenso_eoi_template_id` | int | Template ID for EOI generation. |
|
||||
| `documenso_client_recipient_id` | int | Template recipient slot — client (signing order 1). |
|
||||
| `documenso_developer_recipient_id` | int | Template recipient slot — developer (signing order 2). |
|
||||
| `documenso_approval_recipient_id` | int | Template recipient slot — approver (signing order 3). |
|
||||
| `documenso_developer_name` | string | Display name for developer signer (legacy hardcoded "David Mizrahi"). |
|
||||
| `documenso_developer_email` | string | Developer signer email. |
|
||||
| `documenso_approver_name` | string | Approver display name. |
|
||||
| `documenso_approver_email` | string | Approver email. |
|
||||
| `documenso_webhook_secret` | string | Per-port webhook secret. Receiver tries each enabled secret with timing-safe equal. |
|
||||
| `eoi_default_pathway` | `'documenso-template' \| 'inapp'` | Which path is used when EOI is generated without explicit choice. |
|
||||
| `eoi_send_mode` | `'auto' \| 'manual'` | Auto = send branded invitation email immediately; manual = rep clicks Send. |
|
||||
| `embedded_signing_host` | string | Public host that wraps Documenso URLs into `{host}/sign/<type>/<token>`. |
|
||||
| `documenso_contract_template_id` | int (optional) | Optional template for sales contracts. Blank = upload-and-place-fields per deal. |
|
||||
| `documenso_reservation_template_id` | int (optional) | Optional template for reservation agreements. Same logic as contract. |
|
||||
|
||||
---
|
||||
|
||||
## Document type matrix
|
||||
|
||||
| Type | Generation flow | Signers | Field placement |
|
||||
| --------------- | ----------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- |
|
||||
| **EOI** | Documenso template (`eoi_template_id`) + form-fill values | Static: client, developer, approver (per-port) | Templated — fields baked into Documenso template |
|
||||
| **Contract** | Per-deal upload (drafted custom). Template fallback if configured | Custom per deal — rep specifies | Per-deal placement — default footer-anchored fallback |
|
||||
| **Reservation** | Per-deal upload OR template if configured | Custom per deal | Per-deal placement |
|
||||
|
||||
## Documenso field types
|
||||
|
||||
Custom-uploaded documents (contracts, reservations) need a per-deal field placement step — different documents need different mixes. The CRM exposes the full Documenso-supported field palette so reps can place whatever the document calls for without code changes.
|
||||
|
||||
| Field type | Use case | Needs `fieldMeta`? | What goes in meta |
|
||||
| ---------------- | ------------------------------------------------------- | ------------------ | --------------------------------------------------- |
|
||||
| `SIGNATURE` | Drawn signature — almost every signing flow | No | — |
|
||||
| `FREE_SIGNATURE` | Type-or-draw signature variant | No | — |
|
||||
| `INITIALS` | Per-page initials block | No | — |
|
||||
| `DATE` | Auto-fills the date when the recipient signs | No | — |
|
||||
| `EMAIL` | Auto-fills the recipient's email | No | — |
|
||||
| `NAME` | Auto-fills the recipient's name | No | — |
|
||||
| `TEXT` | Free text input (e.g. address, notes, place of signing) | Yes | `{ text?, label?, required?, readOnly? }` |
|
||||
| `NUMBER` | Numeric input with optional min/max | Yes | `{ numberFormat?, min?, max?, required? }` |
|
||||
| `CHECKBOX` | Boolean / single checkbox | Yes | `{ values: [{ checked, value }], validationRule? }` |
|
||||
| `DROPDOWN` | Pick from a fixed list | Yes | `{ values: [{ value }], defaultValue? }` |
|
||||
| `RADIO` | Mutually-exclusive options | Yes | `{ values: [{ checked, value }] }` |
|
||||
|
||||
Helper: [`fieldTypeNeedsMeta(type)`](../src/lib/services/documenso-client.ts) returns true for the configurable types so the placement UI knows when to surface a config side-panel.
|
||||
|
||||
`fieldMeta` is forwarded verbatim by [`placeFields()`](../src/lib/services/documenso-client.ts) on the v2 path. v1 silently ignores the property — fields render as blank inputs. Configurable behaviour (validation, defaults) only fires on v2 instances.
|
||||
|
||||
---
|
||||
|
||||
## Documenso v1 vs v2 endpoint mapping
|
||||
|
||||
The [`documenso-client.ts`](../src/lib/services/documenso-client.ts) abstracts both. Each function picks v1 or v2 from `getPortDocumensoConfig(portId).apiVersion`.
|
||||
|
||||
| Operation | v1 (1.13–1.32) | v2.x |
|
||||
| ------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| Create document from upload | `POST /api/v1/documents` (body: `{ title, document, recipients }`) | `POST /api/v2/envelope` |
|
||||
| Generate document from template | `POST /api/v1/templates/{id}/generate-document` | (template-from-envelope path) |
|
||||
| Send for signing | `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/{id}/send` |
|
||||
| Place a field | `POST /api/v1/documents/{id}/fields` (PIXEL coords, one at a time) | `POST /api/v2/envelope/field/create-many` (PERCENT, bulk) |
|
||||
| Get document state | `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{id}` |
|
||||
| Send reminder to one recipient | `POST /api/v1/documents/{id}/recipients/{rid}/remind` | `POST /api/v2/envelope/{id}/recipient/{rid}/remind` |
|
||||
| Download finalized PDF | `GET /api/v1/documents/{id}/download` → `{ downloadUrl }` then GET that URL | `GET /api/v2/envelope/{id}/download` (same shape) |
|
||||
| Cancel / void | `DELETE /api/v1/documents/{id}` | `DELETE /api/v2/envelope/{id}` |
|
||||
| Healthcheck | `GET /api/v1/health` | (v1 path used) |
|
||||
|
||||
**Field key rename in v2 responses**: `id` → `documentId` and recipient `id` → `recipientId`. Our [`normalizeDocument()`](../src/lib/services/documenso-client.ts) handles both shapes.
|
||||
|
||||
---
|
||||
|
||||
## Signing-flow lifecycle
|
||||
|
||||
```
|
||||
[rep clicks Generate] (CRM)
|
||||
│
|
||||
▼
|
||||
buildEoiContext(interestId, portId) service
|
||||
│
|
||||
▼
|
||||
generateAndSign(templateId, ctx, signers) creates Documenso doc
|
||||
│
|
||||
▼
|
||||
POST /documents/{id}/send {sendEmail:false} Documenso starts the chain;
|
||||
it does NOT email signers
|
||||
│
|
||||
▼
|
||||
extract signing URLs from response service
|
||||
│
|
||||
▼
|
||||
transformSigningUrl(url, host, role) wrap as {host}/sign/<role>/<token>
|
||||
│
|
||||
▼
|
||||
if eoi_send_mode === 'auto':
|
||||
sendSigningInvitation(client) our branded HTML email goes out
|
||||
else:
|
||||
UI shows the URL + Send button rep dispatches manually
|
||||
```
|
||||
|
||||
When the client signs:
|
||||
|
||||
```
|
||||
Documenso fires DOCUMENT_SIGNED webhook ──► /api/webhooks/documenso
|
||||
│
|
||||
▼
|
||||
verify x-documenso-secret (per-port lookup)
|
||||
│
|
||||
▼
|
||||
update document_signers row: status='signed', signedAt=...
|
||||
│
|
||||
▼
|
||||
if next signer in chain has not been notified:
|
||||
sendSigningInvitation(developer) cascading "your turn" email
|
||||
```
|
||||
|
||||
When the document reaches fully-signed:
|
||||
|
||||
```
|
||||
Documenso fires DOCUMENT_COMPLETED webhook
|
||||
│
|
||||
▼
|
||||
download signed PDF from Documenso
|
||||
│
|
||||
▼
|
||||
store in storage backend → creates files row
|
||||
│
|
||||
▼
|
||||
update document: status='completed', completedAt=...
|
||||
│
|
||||
▼
|
||||
sendSigningCompleted([client, developer, approver], pdfFileId)
|
||||
all parties get the signed PDF
|
||||
│
|
||||
▼
|
||||
update interest: pipelineStage='eoi_signed' (or contract_signed, etc)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Embedded signing on the marketing website
|
||||
|
||||
The CRM emits signing URLs in the form `{embeddedSigningHost}/sign/<role>/<token>`. The marketing website ([Port Nimara/Website/pages/sign/[type]/[token].vue](../../Port%20Nimara/Website/pages/sign/%5Btype%5D/%5Btoken%5D.vue)) hosts the page, embeds Documenso via `@documenso/embed-vue`'s `<EmbedSignDocument>`, and POSTs back to the CRM webhook on completion.
|
||||
|
||||
For the embed to work, the Documenso instance MUST send `Access-Control-Allow-Origin` headers permitting the website origin.
|
||||
|
||||
### nginx CORS block to apply on `signatures.portnimara.dev`
|
||||
|
||||
Add to the relevant `server { ... }` block:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
# CORS for embedded signing — allow the marketing-website origin
|
||||
# to load the Documenso signing iframe.
|
||||
add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
|
||||
# Preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' 'https://portnimara.com' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# ... your existing proxy_pass block to Documenso
|
||||
}
|
||||
```
|
||||
|
||||
To support multiple website origins (e.g. Port Amador hosted on a different domain), use a regex:
|
||||
|
||||
```nginx
|
||||
set $cors_origin "";
|
||||
if ($http_origin ~* "^https://(portnimara\.com|portamador\.com)$") {
|
||||
set $cors_origin $http_origin;
|
||||
}
|
||||
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's deferred vs landed in this build
|
||||
|
||||
**Landed:**
|
||||
|
||||
- Per-port admin settings — every Documenso config knob is exposed at `/admin/documenso`
|
||||
- Branded invitation, completion, and reminder email templates
|
||||
- `transformSigningUrl()` for `{host}/sign/<role>/<token>` URL wrapping
|
||||
- Documenso v1 + v2 dual-version client (existing)
|
||||
- Webhook handler with timing-safe per-port secret resolution (existing)
|
||||
- Contract + Reservation tab UI shells with paper-signed upload + "send for signing" placeholder
|
||||
- Stage-conditional tab visibility for EOI / Contract / Reservation
|
||||
|
||||
**Landed in Phase 2-4 (2026-05-13):**
|
||||
|
||||
- **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:**
|
||||
|
||||
- Apply the nginx CORS block above on your prod Documenso instance.
|
||||
- Decide whether to upgrade prod Documenso to v2 (would unlock cleaner field placement + better envelope semantics).
|
||||
- Configure each port's developer/approver names and template IDs at `/[portSlug]/admin/documenso`.
|
||||
@@ -19,18 +19,23 @@ The template exposes eight text fields (`formValues` keys) and two boolean check
|
||||
|
||||
## Field mapping
|
||||
|
||||
| Documenso key | Type | Legacy source | New `EoiContext` path | Notes |
|
||||
| -------------- | ------- | --------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). |
|
||||
| `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. |
|
||||
| `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. |
|
||||
| `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. |
|
||||
| `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Send as string. Documenso doesn't enforce numeric format. |
|
||||
| `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. |
|
||||
| `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". |
|
||||
| `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | One berth per reservation. Multi-berth case was multi-interest in legacy. |
|
||||
| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). |
|
||||
| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. |
|
||||
The legacy template (Documenso template `8`, configured in production) auto-fills exactly the fields below. All eight text fields + two booleans are populated by `buildDocumensoPayload()` from the resolved `EoiContext`. Anything else on the form (signature, date, terms acknowledgment) is filled in by the client inside Documenso.
|
||||
|
||||
| Documenso key | Type | Legacy source | New `EoiContext` path | Notes |
|
||||
| -------------- | ------- | --------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). |
|
||||
| `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. |
|
||||
| `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. |
|
||||
| `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. Empty string when no yacht is linked yet. |
|
||||
| `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Boat dimension. Send as string. Documenso doesn't enforce numeric format. Empty string when not applicable. |
|
||||
| `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. |
|
||||
| `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". |
|
||||
| `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | The interest's PRIMARY berth (resolved via `interest_berths.is_primary=true`). Empty string when no primary set. |
|
||||
| `Berth Range` | text | (new) | `context.eoiBerthRange` | **NEW IN PHASE 5** — compact range string for multi-berth EOIs (e.g. `"A1-A3, B5-B7"`) covering every junction row marked `is_in_eoi_bundle=true`. Empty string when the bundle is empty. **The live Documenso template (id `8`) does NOT yet have this field. Add a `Berth Range` text field to the template before multi-berth EOIs render the range; until then Documenso silently drops the value and only `Berth Number` (the primary mooring) renders.** |
|
||||
| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). |
|
||||
| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. |
|
||||
|
||||
**Backwards-compatibility guarantee**: every legacy `formValues` key is still emitted with the same name and type. The only addition is `Berth Range` (Phase 5). Documenso silently ignores unknown formValues keys, so old templates that don't have `Berth Range` will simply not render it — single-berth EOIs continue to work identically. No template changes are required for legacy use.
|
||||
|
||||
## Document `meta` fields (non-`formValues`)
|
||||
|
||||
|
||||
188
docs/error-handling.md
Normal file
188
docs/error-handling.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Error handling
|
||||
|
||||
## Overview
|
||||
|
||||
Every authenticated request runs inside an `AsyncLocalStorage` frame
|
||||
that carries a `requestId` (UUID) plus the resolved `portId` / `userId`
|
||||
/ HTTP method / path / start time. The id surfaces:
|
||||
|
||||
- as `X-Request-Id` on every response header (success or failure)
|
||||
- inside every pino log line emitted during the request
|
||||
- in the JSON error body returned to the client (`requestId` field)
|
||||
- as the primary key of the `error_events` row written when a 5xx fires
|
||||
|
||||
A user who hits a failure can copy the **Reference ID** from the toast
|
||||
and a super admin can paste it into `/<port>/admin/errors/<requestId>`
|
||||
to see the full request context, sanitized body, error stack, and a
|
||||
heuristic "likely culprit" hint.
|
||||
|
||||
## Throwing errors from a service
|
||||
|
||||
Use `CodedError` with a registered code:
|
||||
|
||||
```ts
|
||||
import { CodedError } from '@/lib/errors';
|
||||
|
||||
if (!hasReceipts && !ack) {
|
||||
throw new CodedError('EXPENSES_RECEIPT_REQUIRED');
|
||||
}
|
||||
```
|
||||
|
||||
The code drives:
|
||||
|
||||
- the HTTP status (defined in `src/lib/error-codes.ts`)
|
||||
- the **plain-text user-facing message** (no jargon — written for the
|
||||
rep on the phone with a customer)
|
||||
- the stable identifier the user can quote to support
|
||||
|
||||
For more verbose internal context — admin-only — use `internalMessage`:
|
||||
|
||||
```ts
|
||||
throw new CodedError('CROSS_PORT_LINK_REJECTED', {
|
||||
internalMessage: `interest ${a.id} (port ${a.portId}) ↔ berth ${b.id} (port ${b.portId})`,
|
||||
});
|
||||
```
|
||||
|
||||
The `internalMessage` lands in the `error_events` row and the admin
|
||||
inspector but **never** reaches the client.
|
||||
|
||||
## Adding a new error code
|
||||
|
||||
1. Open `src/lib/error-codes.ts`.
|
||||
2. Add an entry to the `ERROR_CODES` map. Convention: `DOMAIN_REASON`
|
||||
in SCREAMING_SNAKE_CASE.
|
||||
|
||||
```ts
|
||||
FOO_INVALID_BAR: {
|
||||
status: 400,
|
||||
userMessage: 'That bar value is no good. Please try another.',
|
||||
},
|
||||
```
|
||||
|
||||
3. Use it: `throw new CodedError('FOO_INVALID_BAR')`.
|
||||
4. The code, status, and message are now contractually stable —
|
||||
never rename a code once it has shipped. Documentation, UI, and
|
||||
external integrations may pin to it.
|
||||
|
||||
## Plain-text message guidelines
|
||||
|
||||
User-facing messages should:
|
||||
|
||||
- Avoid internal jargon (no "constraint violation", "FK", "row lock").
|
||||
- Be written for a rep on the phone with a customer.
|
||||
- Include the suggested next action when natural ("Ask an admin if you
|
||||
think you should").
|
||||
- Not include any technical detail that doesn't help the user — the
|
||||
request id + error code carry that.
|
||||
|
||||
Verbose technical detail belongs in `internalMessage` (admin-only).
|
||||
|
||||
## Client side
|
||||
|
||||
In a `useMutation`, render errors with the shared helper:
|
||||
|
||||
```ts
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => apiFetch('/api/v1/foo', { method: 'POST', body: { ... } }),
|
||||
onSuccess: () => { ... },
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
```
|
||||
|
||||
The toast renders three lines:
|
||||
|
||||
```
|
||||
{plain-text message}
|
||||
|
||||
Error code: EXPENSES_RECEIPT_REQUIRED
|
||||
Reference ID: 8f3c-ab12-… [Copy ID]
|
||||
```
|
||||
|
||||
The "Copy ID" action puts the request id on the clipboard so the
|
||||
user can paste it into a support ticket.
|
||||
|
||||
## Admin inspector
|
||||
|
||||
`/<port>/admin/errors` lists captured 5xx errors:
|
||||
|
||||
- Status badge + method + path
|
||||
- "Likely culprit" badge (heuristic — Postgres SQLSTATE, error name,
|
||||
stack-path patterns, message keywords)
|
||||
- Truncated error name + message
|
||||
- Timestamp + reference id
|
||||
|
||||
Click any row for `/<port>/admin/errors/<requestId>` which shows:
|
||||
|
||||
- Request shape (method / path / when / duration / port / user / IP / UA)
|
||||
- Likely culprit + plain-English hint + subsystem tag
|
||||
- Full error name, message, stack head (first 4 KB)
|
||||
- Sanitized request body excerpt (max 1 KB; sensitive keys redacted)
|
||||
- Raw metadata (Postgres SQLSTATE codes, internalMessage, etc.)
|
||||
|
||||
Permission: `admin.view_audit_log`. Super admins see every port's
|
||||
errors; regular admins are scoped to their active port.
|
||||
|
||||
## What gets persisted
|
||||
|
||||
| Status | error_events row? | Toast shows code? |
|
||||
| ------ | ----------------- | ----------------- |
|
||||
| 4xx | No | Yes |
|
||||
| 5xx | **Yes** | Yes |
|
||||
|
||||
4xx errors are user-action mistakes (validation, not-found, permission
|
||||
denied). They're visible in the audit log but not the error inspector
|
||||
— that table is reserved for platform faults.
|
||||
|
||||
5xx errors hit the `errorEvents` table via `captureErrorEvent` inside
|
||||
`errorResponse`, which:
|
||||
|
||||
1. Reads the request context from ALS.
|
||||
2. Sanitizes + truncates the body (1 KB cap, sensitive keys redacted).
|
||||
3. Pulls Postgres `code` / `severity` / `cause.code` if the underlying
|
||||
error is a `postgres` driver error.
|
||||
4. Truncates the stack to 4 KB.
|
||||
5. Inserts one row keyed on `requestId` with `ON CONFLICT DO NOTHING`.
|
||||
|
||||
Failure to persist NEVER throws — the user is already getting an
|
||||
error response; we don't want a logging-pipeline failure to mask it.
|
||||
|
||||
## Likely-culprit classifier
|
||||
|
||||
`src/lib/error-classifier.ts` runs four passes against an
|
||||
`error_events` row, first match wins:
|
||||
|
||||
1. **Postgres SQLSTATE** (from `metadata.code`): 23502 NOT NULL,
|
||||
23503 FK, 23505 unique, 23514 CHECK, 42703 schema drift, 42P01
|
||||
missing table, 40001 serialization, 53300 connection limit, …
|
||||
2. **Error class name**: `AbortError`, `TimeoutError`, `FetchError`,
|
||||
`ZodError`.
|
||||
3. **Stack path**: `/lib/storage/`, `/lib/email/`, `documenso`,
|
||||
`openai|claude`, `/queue/workers/`.
|
||||
4. **Message free-text**: `econnrefused`, `rate limit`, `timeout`,
|
||||
`unauthorized|invalid api key`.
|
||||
|
||||
Returns `null` when nothing matches; the inspector renders
|
||||
"Uncategorized" in that case. Adding a new heuristic is a one-line
|
||||
edit to the relevant array.
|
||||
|
||||
## Pruning
|
||||
|
||||
`error_events` rows are dropped after 90 days by the maintenance
|
||||
worker (TODO: confirm the worker has the deletion path; if not, add
|
||||
a periodic job that runs `DELETE FROM error_events WHERE created_at <
|
||||
now() - interval '90 days'`).
|
||||
|
||||
## Migration path for legacy throws
|
||||
|
||||
Existing `NotFoundError` / `ForbiddenError` / `ConflictError` /
|
||||
`ValidationError` / `RateLimitError` still work — the user-facing
|
||||
messages on these classes have been rewritten to plain-text defaults.
|
||||
|
||||
Migration to `CodedError` happens opportunistically: when touching a
|
||||
service to fix something else, swap the throw site for a registered
|
||||
code.
|
||||
|
||||
A follow-up audit pass should walk `git grep "throw new ValidationError"`
|
||||
and migrate the user-impactful ones to specific codes.
|
||||
123
docs/operations/outbound-comms-safety.md
Normal file
123
docs/operations/outbound-comms-safety.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Outbound communications safety net
|
||||
|
||||
**Last reviewed:** 2026-05-03
|
||||
**Owner:** matt@portnimara.com
|
||||
|
||||
This doc enumerates every channel through which the CRM can produce
|
||||
outbound communication (email, document signing, webhooks) and describes
|
||||
how each channel respects the `EMAIL_REDIRECT_TO` env var. The goal: a
|
||||
single environment flip pauses **all** outbound traffic, so a production
|
||||
data import, dedup migration dry-run, or staging environment can run
|
||||
against real data without anyone getting paged or spammed.
|
||||
|
||||
> **Single env switch:** when `EMAIL_REDIRECT_TO` is set to an address,
|
||||
> all outbound communication is rerouted there or short-circuited. Unset
|
||||
> it in production.
|
||||
|
||||
---
|
||||
|
||||
## Channels
|
||||
|
||||
### 1. Direct email (`sendEmail`)
|
||||
|
||||
**Path:** `src/lib/email/index.ts` → `sendEmail()` → nodemailer SMTP transport.
|
||||
|
||||
**Safety:** YES — covered.
|
||||
|
||||
When `EMAIL_REDIRECT_TO` is set, `sendEmail()` rewrites the `to` header
|
||||
to the redirect address and prefixes the subject with
|
||||
`[redirected from <orig>]`. The original recipient is logged.
|
||||
|
||||
**Call sites** (all flow through `sendEmail`, so all are covered):
|
||||
|
||||
- `src/lib/services/portal-auth.service.ts` — portal activation + reset
|
||||
- `src/lib/services/crm-invite.service.ts` — CRM user invitations
|
||||
- `src/lib/services/document-templates.ts` — template-generated PDFs sent
|
||||
as attachments (the PDF body is generated locally; the email itself
|
||||
goes through SMTP)
|
||||
- `src/lib/services/email-compose.service.ts` — ad-hoc emails composed
|
||||
in the in-app UI
|
||||
- `src/lib/services/gdpr-export.service.ts` — GDPR export delivery
|
||||
|
||||
### 2. Documenso e-signature recipients
|
||||
|
||||
**Path:** `src/lib/services/documenso-client.ts` → `createDocument()` /
|
||||
`generateDocumentFromTemplate()` → Documenso REST API.
|
||||
|
||||
**Safety:** YES — covered as of 2026-05-03.
|
||||
|
||||
Documenso's own server sends the signing-request email on our behalf.
|
||||
We can't intercept that at the SMTP layer because it's external. The
|
||||
fix is at the REST-call boundary: when `EMAIL_REDIRECT_TO` is set,
|
||||
`createDocument` rewrites every recipient's email to the redirect
|
||||
address and prefixes the recipient name with `(was: <orig email>)` so
|
||||
the doc is still traceable to its intended recipient.
|
||||
`generateDocumentFromTemplate` does the same for both shapes the
|
||||
template-generate endpoint accepts (v1.13 `formValues.*Email` keys and
|
||||
v2.x `recipients` array).
|
||||
|
||||
The redirect happens **before** the API call, so even if Documenso has
|
||||
its own retry logic the original email never leaves our process.
|
||||
|
||||
### 3. Webhooks (outbound to user-configured URLs)
|
||||
|
||||
**Path:** `src/lib/queue/workers/webhooks.ts` → BullMQ job → `fetch(webhook.url, ...)`.
|
||||
|
||||
**Safety:** YES — covered as of 2026-05-03.
|
||||
|
||||
When `EMAIL_REDIRECT_TO` is set, the webhook worker short-circuits
|
||||
before the HTTP call. The delivery row is marked `dead_letter` with a
|
||||
human-readable reason so it's still visible in the deliveries listing.
|
||||
The SSRF guard remains in place independently.
|
||||
|
||||
### 4. WhatsApp / phone deep-links
|
||||
|
||||
**Path:** `<a href="https://wa.me/...">` and `<a href="tel:...">` in
|
||||
client / interest detail headers.
|
||||
|
||||
**Safety:** N/A — user-initiated only.
|
||||
|
||||
These are deep links the user explicitly clicks. No automated dispatch.
|
||||
A deep link click opens the user's WhatsApp / phone app, which is the
|
||||
intended interaction. No safety net needed.
|
||||
|
||||
### 5. SMS
|
||||
|
||||
Not implemented. The `interests.preferredContactMethod` enum includes
|
||||
`'sms'` as a value but no sending path exists. If/when SMS is added (e.g.
|
||||
via Twilio), the new send function should respect `EMAIL_REDIRECT_TO`
|
||||
the same way `sendEmail` does — log the original number, drop the
|
||||
message, or reroute to a configurable `SMS_REDIRECT_TO` env.
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist before importing real data
|
||||
|
||||
- [ ] `.env` has `EMAIL_REDIRECT_TO=<my-address>` set.
|
||||
- [ ] Restart dev server (or worker) so the new env is picked up — env
|
||||
vars are read at import time in some paths.
|
||||
- [ ] Send a test email via `pnpm tsx scripts/dev-trigger-portal-invite.ts`
|
||||
or similar. Confirm subject is prefixed with `[redirected from ...]`.
|
||||
- [ ] Trigger an EOI send through the UI (any client). Confirm Documenso
|
||||
shows the redirect address as recipient (not the real client email).
|
||||
- [ ] If any webhooks are configured, trigger an event that fires one and
|
||||
confirm the delivery is recorded as `dead_letter` with the
|
||||
"EMAIL_REDIRECT_TO is set" reason.
|
||||
- [ ] Run the NocoDB migration `--dry-run` to count clients/interests; the
|
||||
`--apply` step is what creates real records but emails/webhooks are
|
||||
still gated by the redirect env.
|
||||
|
||||
## Production cutover
|
||||
|
||||
When ready to go live:
|
||||
|
||||
1. Run a final dry-run of the data migration with `EMAIL_REDIRECT_TO` set
|
||||
to a sandbox address.
|
||||
2. Verify the snapshot looks right (counts, client coverage).
|
||||
3. Unset `EMAIL_REDIRECT_TO` in the production env.
|
||||
4. Restart the app + worker.
|
||||
5. Run the migration with `--apply`. From this point forward, real
|
||||
recipients will receive real comms.
|
||||
|
||||
If you ever need to re-pause outbound (e.g. handling a security incident,
|
||||
re-importing on top of existing data), set `EMAIL_REDIRECT_TO` again.
|
||||
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.
|
||||
1918
docs/superpowers/plans/2026-04-29-mobile-foundation.md
Normal file
1918
docs/superpowers/plans/2026-04-29-mobile-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
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
189
docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Normal file
189
docs/superpowers/specs/2026-04-29-mobile-optimization-design.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Mobile Optimization Design
|
||||
|
||||
**Status**: Design approved 2026-04-29 — pending plan.
|
||||
**Plan decomposition**: Foundation PR (§3) is one implementation plan; per-page migration phases (§5) become follow-up plans, scoped per phase.
|
||||
**Branch base**: stacks on `refactor/data-model`.
|
||||
**Out of scope**: Phase B/C features, desktop redesign, Capacitor wrapper, swipe-actions on rows, native menus, server-driven UI.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background
|
||||
|
||||
The CRM was built desktop-first. A 2026-04-29 mobile audit captured every authenticated and public page across the active iPhone viewport range. Findings:
|
||||
|
||||
1. **No `viewport` meta in the root layout** (one exists only in the scanner PWA sub-layout, `src/app/(scanner)/[portSlug]/scan/layout.tsx`). Without it, iOS Safari renders pages at the default 980px logical width and zooms out to fit — text becomes unreadable and touch targets sub-tappable. Playwright's `isMobile` emulation in the audit forces 393px-wide rendering, which exposes the layout breakage you'd otherwise have to discover by pinching to zoom.
|
||||
2. **Topbar overflows**. Search input + port switcher + sign-out button cram into one row; sign-out clips off the right edge as a half-visible blue bar on every authenticated page.
|
||||
3. **Tables render as desktop tables**. Every list page (clients, yachts, companies, invoices, expenses, interests, audit, users, etc.) shows truncated columns with horizontal scroll.
|
||||
4. **Page headers don't downsize**. Titles like "Dashboard" truncate to "Dash..."; primary action buttons (`+ New Client`) overlap their subtitles.
|
||||
5. **Detail page action chips overflow**. The chip row ("Invite to portal | GDPR export | Archive | …") horizontally overflows on every detail page.
|
||||
6. **One half-good pattern**: detail pages already collapse their tabs to a `<select>` dropdown on small screens. Worth extending.
|
||||
7. **Auth + scanner pages are already mobile-first** (`/login`, `/[portSlug]/scan`). Reference for the "what good looks like" target.
|
||||
|
||||
The audit harness (`tests/e2e/audit/mobile.spec.ts` + `mobile-audit` Playwright project) is added on this branch (not yet committed); re-runs regenerate `.audit/mobile/` (gitignored).
|
||||
|
||||
## 2. Approach
|
||||
|
||||
**Adaptive shell + responsive content** — chosen over (a) per-page conditional render, (b) a separate `(mobile)` route group, and (c) Tailwind-only responsive.
|
||||
|
||||
The "native feel" the user wants comes from the chrome — bottom tab bar, sheet modals, sticky compact header, safe-area awareness. Page content (forms, lists, details) doesn't need duplication; it gets responsive via shared mobile-aware primitives. This concentrates the dedicated-mobile work in ~10 components and keeps content single-source.
|
||||
|
||||
**Breakpoint**: Tailwind `lg` (1024px). Below `lg`, the mobile shell renders. At and above, the existing desktop shell is untouched.
|
||||
|
||||
### 2.1 Target iPhone viewport range
|
||||
|
||||
The mobile shell + content primitives must look correct across the full active iPhone viewport range (portrait):
|
||||
|
||||
| Tier | Models | Viewport |
|
||||
| ------------------------------------------ | ----------------------------------------------- | -------- |
|
||||
| Narrowest | iPhone SE 2nd / 3rd gen | 375×667 |
|
||||
| Standard | iPhone 12/13/14 (and Mini) | 390×844 |
|
||||
| Standard newer | iPhone 15 / 15 Pro / 16 | 393×852 |
|
||||
| Pro newer (Dynamic Island, thinner bezels) | iPhone 16 Pro / 17 Pro | 402×874 |
|
||||
| Plus / older Max | iPhone 14 Plus / 15 Plus / 15 Pro Max / 16 Plus | 430×932 |
|
||||
| Pro Max | iPhone 16 Pro Max / 17 Pro Max | 440×956 |
|
||||
|
||||
**Anchors used by audit and design validation**: 375×667 (worst-case narrow + short), 393×852 (most common current), 402×874 (current Pro), 440×956 (current Pro Max). Models within ±5px of an anchor (390, 430) are skipped — primitives that look correct at the anchors will look correct at neighbors.
|
||||
|
||||
**Dynamic Island**: iPhone 14 Pro and later have a larger top safe-area inset (~59px vs ~47px on classic-notch models). The CSS `env(safe-area-inset-top)` we expose as `pt-safe` handles this transparently — no per-model code paths.
|
||||
|
||||
**Landscape**: out of scope for this design. Phones in landscape are rare for CRM-style work; if needed later, the mobile shell at landscape widths would still fall under `lg` and would just stretch. Tablet landscape is addressed in the §5 tablet-pass phase.
|
||||
|
||||
**Routing**: no new route group. URLs and middleware unchanged. RBAC, services, queries, validators, RHF/zod forms, TanStack Query stores, socket.io — all unchanged.
|
||||
|
||||
## 3. Foundation PR
|
||||
|
||||
A single branch lands the infra + shell + primitives before any per-page work. After this merges, every authenticated page already gains: real viewport meta, no clipped topbar, bottom tab navigation, safe-area handling, and 44px touch targets — without any per-page edits.
|
||||
|
||||
### 3.1 Infrastructure
|
||||
|
||||
- `viewport` export in `src/app/layout.tsx` — `width=device-width, initial-scale=1, viewport-fit=cover`.
|
||||
- `theme-color` meta + `apple-mobile-web-app-capable` meta + `apple-mobile-web-app-status-bar-style` for PWA-ish status-bar integration.
|
||||
- Safe-area CSS variables (`env(safe-area-inset-*)`) exposed as Tailwind utilities (`pt-safe`, `pb-safe`, `pl-safe`, `pr-safe`).
|
||||
- `useIsMobile()` hook in `src/hooks/use-is-mobile.ts` — backed by `window.matchMedia('(max-width: 1023.98px)')`, no resize listener.
|
||||
- Server-side body-class detection: the root layout (`src/app/layout.tsx`) reads the `user-agent` request header via `next/headers`'s `headers()`, runs a small known-mobile-token check (Mobile / iPhone / iPad / Android — no library), and renders `<body data-form-factor="mobile|desktop">`. No middleware needed. CSS `[data-form-factor="mobile"]` reveals the mobile shell. The CSS media-query fallback (`@media (max-width: 1023.98px)`) handles UA misclassification (e.g., desktop browser resized to narrow width, or stripped UA).
|
||||
|
||||
### 3.2 Mobile shell
|
||||
|
||||
Both desktop and mobile shells are rendered to the DOM by the root layout; CSS reveals one and hides the other based on `[data-form-factor="mobile"]` plus a `@media (max-width: 1023.98px)` fallback. The existing `<Sidebar>` and `<Topbar>` components stay unchanged for the desktop shell. The mobile shell is wholly new:
|
||||
|
||||
- **`<MobileLayout>`** (`src/components/layout/mobile/mobile-layout.tsx`)
|
||||
Fixed 52px compact topbar (safe-area aware) + scrollable content + fixed 56px bottom tab bar (safe-area inset). Renders instead of the desktop sidebar+topbar shell when the form factor resolves to mobile.
|
||||
|
||||
- **`<MobileTopbar>`**
|
||||
Page title (auto-truncating, single-line) + back button when route depth > 1 + single primary action slot (passed via context from the page) + port-switcher behind a `<Sheet>` trigger.
|
||||
|
||||
- **`<MobileBottomTabs>`**
|
||||
Fixed 5 tabs: **Dashboard / Clients / Yachts / Berths / More**. Active state from current path. Lucide icons (no emoji). Badge support for the alerts count.
|
||||
|
||||
- **`<MoreSheet>`**
|
||||
Bottom sheet opened by the More tab. Holds the long tail in a scrollable list grouped by section: Companies, Interests, Invoices, Expenses, Documents, Email, Alerts, Reports, Reminders, Settings, Admin (with admin nesting one level deep into a child sheet).
|
||||
|
||||
- **`<MobileLayoutProvider>`**
|
||||
React context that lets each page push its title, back button, and primary action slot to `<MobileTopbar>` via a hook (`useMobileChrome({ title, action })`).
|
||||
|
||||
### 3.3 Primitives
|
||||
|
||||
All built once in `src/components/shared/`. Render desktop-style above `lg`, mobile-style below.
|
||||
|
||||
- **`<Sheet>`** — vaul-based bottom sheet on mobile, falls through to existing Radix `<Dialog>` on desktop. Same API as `<Dialog>` so adoption is mechanical.
|
||||
- **`<DataView>`** — accepts the same column defs the codebase uses today via TanStack Table. Above `lg`: renders the existing table. Below `lg`: renders a card list with a per-row `cardRender({ row }) => ReactNode` callback. Filter chips stay above the list; sort moves into a `<Sheet>` opened by a sort button.
|
||||
- **`<PageHeader>`** — title + optional subtitle + actions. Truncates title to one line, stacks actions to a second row on mobile, hides subtitle below `sm` if action row is present.
|
||||
- **`<ActionRow>`** — chip-style action group; `flex-nowrap overflow-x-auto scroll-smooth snap-x` on mobile, no overflow on desktop.
|
||||
- **`<DetailPageShell>`** — wraps detail pages with: sticky compact header (entity name, primary status), tab dropdown selector (existing pattern, extracted), scrollable content area, optional sticky bottom action bar (Save / Archive / etc.) on mobile that pins above the bottom tab bar.
|
||||
- **`<FilterChips>`** — chip-row filter UI used by `<DataView>`. Active filters are dismissable chips; "Add filter" opens a `<Sheet>`.
|
||||
|
||||
### 3.4 Default style adjustments
|
||||
|
||||
- `<Button>` and `<Input>` defaults: `min-h-11` (44px, Apple HIG touch-target).
|
||||
- `<Input>` and `<Textarea>` body text: `text-base` (16px) so iOS doesn't zoom on focus.
|
||||
- `<Dialog>` default base styling tweaked so any remaining unmigrated dialogs render full-screen on mobile (until they get migrated to `<Sheet>`).
|
||||
|
||||
### 3.5 Bundle impact
|
||||
|
||||
Both shells render server-side and switch via the `data-form-factor` body attribute, so both ship to every client (dynamic-importing one would cause a hydration flash). Rough estimate ~40KB gzipped added to the layout subtree for the mobile shell + new primitives (vaul ≈ 5KB gz, the rest is in-house components). Verify post-build with `pnpm build` and adjust if it's materially higher. Acceptable trade for no flash and no UA-based render-time branching.
|
||||
|
||||
### 3.6 PWA assets
|
||||
|
||||
The PWA scanner already references `icon-192.png`, `icon-512.png`, `icon-512-maskable.png` from `public/`, but those files don't exist yet (separate flagged blocker). The mobile shell adds an `apple-touch-icon` reference too. The Foundation PR includes placeholder PNGs so home-screen install works; production-quality icons can replace them without a code change.
|
||||
|
||||
## 4. Per-page playbook
|
||||
|
||||
Once foundation lands, each page follows the same workflow:
|
||||
|
||||
1. Open the page in headed Playwright at the anchor viewports per §2.1 (start at 393×852 for the iteration loop, spot-check 375 and 440 before declaring done).
|
||||
2. Replace any `<Dialog>` with `<Sheet>`.
|
||||
3. If list page: wrap the table in `<DataView>` and provide a `cardRender` callback. The 2-3 fields shown on the card are decided per page during migration with the user.
|
||||
4. Replace the ad-hoc page header with `<PageHeader>`.
|
||||
5. Replace ad-hoc action button rows with `<ActionRow>`.
|
||||
6. Touch up any custom embedded widgets the page uses (rare for simple pages, common for `email`, `documents`, `expenses/scan`).
|
||||
7. User reviews live in the headed browser, points out tweaks, iterate.
|
||||
|
||||
Most pages take 5–15 minutes in this loop. Heavy pages (email inbox, documents hub) may take 30–60 because the embedded widgets need their own mobile treatment beyond the primitives.
|
||||
|
||||
## 5. Migration sequence
|
||||
|
||||
After foundation PR:
|
||||
|
||||
1. **Quick-win sweep** (~half day) — pages mostly fixed by foundation alone. Just need `<PageHeader>` swap-in (no list-card conversion, no detail-shell wrap):
|
||||
`dashboard` (overview), `settings` (user-profile), `reports`, and the admin sub-pages that are forms or stat cards: `admin/settings`, `admin/branding`, `admin/forms`, `admin/ocr`, `admin/roles`, `admin/tags`, `admin/documenso`, `admin/templates`, `admin/custom-fields`, `admin/monitoring`, `admin/backup`, `admin/webhooks`, `admin/import`, `admin/ports`.
|
||||
2. **List pages** (~1–2 days) — convert via `<DataView>` + per-page `cardRender`:
|
||||
`clients`, `yachts`, `companies`, `berths`, `interests`, `invoices`, `expenses`, `alerts`, `reminders`, `admin/audit`, `admin/users`.
|
||||
3. **Heavy pages** (~1 day each) — embedded widgets need their own mobile treatment beyond the primitives:
|
||||
`documents` (sig-tracking + filters from Phase A), `email` (thread list + reader + composer).
|
||||
4. **Detail pages** (~1–2 days) — wrap in `<DetailPageShell>`, extend the tab-dropdown pattern, add sticky bottom action shelf:
|
||||
`clients/[clientId]`, `yachts/[yachtId]`, `companies/[companyId]`, `berths/[berthId]`, `invoices/[id]`, `expenses/[id]`.
|
||||
5. **Forms & wizards** — touch-up only, since `<Input>`/`<Button>` defaults handle the bulk:
|
||||
`invoices/new` (3-step wizard), `expenses/scan` (already mobile-first, just verify).
|
||||
6. **Portal** — same patterns, smaller scope:
|
||||
authenticated: `portal/dashboard`, `portal/invoices`, `portal/my-yachts`, `portal/documents`, `portal/interests`, `portal/my-reservations`. Public: `portal/login`, `portal/activate`, `portal/forgot-password`, `portal/reset-password` (already styled by `<BrandedAuthShell>` — just verify).
|
||||
7. **Tablet pass** — re-audit at iPad Air 11" portrait (820×1180) and landscape (1180×820), iPad Air 13" portrait (1024×1366) and landscape (1366×1024). The 820 portrait case will hit the mobile shell (820 < 1024) and probably want a "tablet-portrait" treatment with sidebar visible — flagged for design refinement at that phase, not now. The other three viewports fall above `lg` and use the desktop shell unchanged.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Mobile audit project** (`mobile-audit` in `playwright.config.ts`) is the regression harness. Re-runs after every page-migration PR; output goes to `.audit/mobile/` (gitignored). Audit covers the four anchor viewports defined in §2.1: 375×667, 393×852, 402×874, 440×956. Run time ~14 min headed.
|
||||
- **Smoke project** gets a curated mobile-viewport variant (~10 pages at the 393×852 anchor) — adds ~2 min to CI; full audit stays out of CI to avoid the ~14 min cost.
|
||||
- **Visual baselines** — `visual` project gets new mobile snapshots at the 393×852 anchor for: dashboard, clients-list, clients-detail, invoices-list, invoices-new, scan, documents, login. Regenerate with `--update-snapshots` after intentional changes (existing convention).
|
||||
- **Anchor device descriptors** lifted into a shared fixture at `tests/e2e/fixtures/devices.ts` (one per anchor in §2.1) so specs don't redefine viewport.
|
||||
- **No new unit tests** for the primitives — they are presentational. Coverage comes from visual + integration runs.
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
- **Bottom-tab taxonomy**: locked at Dashboard / Clients / Yachts / Berths / More for now. The More sheet holds everything else losslessly, so this is reversible — if real usage suggests a different top-5 (e.g., Interests or Invoices in the tabs), swap them later without code restructure.
|
||||
- **`refactor/data-model` push order**: 155 commits unpushed. Foundation PR can stack on top and rebase, or wait until that branch merges. Decision deferred to user.
|
||||
- **Desktop touch-target adjustments**: bumping `<Button>`/`<Input>` to `min-h-11` will affect desktop too. Verify visually that no desktop layout breaks; if any does, scope the bump to mobile-only via the `data-form-factor` attribute.
|
||||
|
||||
## 8. Files to create
|
||||
|
||||
```
|
||||
src/hooks/use-is-mobile.ts
|
||||
src/components/layout/mobile/
|
||||
mobile-layout.tsx
|
||||
mobile-topbar.tsx
|
||||
mobile-bottom-tabs.tsx
|
||||
more-sheet.tsx
|
||||
mobile-layout-provider.tsx
|
||||
src/components/shared/
|
||||
sheet.tsx (new — vaul wrapper)
|
||||
data-view.tsx (new — table↔card)
|
||||
page-header.tsx (new)
|
||||
action-row.tsx (new)
|
||||
detail-page-shell.tsx (new)
|
||||
filter-chips.tsx (new)
|
||||
src/app/layout.tsx (modified — viewport export, theme-color, UA-derived data-form-factor body attribute via headers())
|
||||
public/icon-192.png (placeholder PWA asset)
|
||||
public/icon-512.png (placeholder PWA asset)
|
||||
public/icon-512-maskable.png (placeholder PWA asset)
|
||||
public/apple-touch-icon.png (placeholder PWA asset)
|
||||
tailwind.config.ts (modified — safe-area utilities, touch-target defaults)
|
||||
tests/e2e/fixtures/devices.ts (new — shared device descriptors)
|
||||
```
|
||||
|
||||
## 9. Files to modify per page
|
||||
|
||||
Per the playbook in §4, each page typically needs:
|
||||
|
||||
- One swap of header markup → `<PageHeader>`.
|
||||
- For list pages: one wrap of table → `<DataView>` + add `cardRender` callback.
|
||||
- For detail pages: wrap in `<DetailPageShell>`.
|
||||
- Replace `<Dialog>` imports with `<Sheet>`.
|
||||
- No service, validator, query, or schema changes anywhere.
|
||||
564
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md
Normal file
564
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md
Normal file
@@ -0,0 +1,564 @@
|
||||
# Client Deduplication and NocoDB Migration Design
|
||||
|
||||
**Status**: Design draft 2026-05-03 — pending approval.
|
||||
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
|
||||
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
|
||||
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
|
||||
|
||||
---
|
||||
|
||||
## 1. Background
|
||||
|
||||
### 1.1 Why this exists
|
||||
|
||||
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
|
||||
|
||||
- **252 Interests rows** in NocoDB, against an estimated ~190–200 unique humans (~20–25% duplication rate).
|
||||
- **35 Residential Interests rows** in a parallel residential pathway with the same conflation.
|
||||
- **64 Website Interest Submissions + 47 Website Contact Form Submissions + 1 EOI Supplemental Form** as inbound capture surfaces.
|
||||
- **No Clients table.** The conflated structure is structural, not accidental.
|
||||
|
||||
The new CRM (`src/lib/db/schema/clients.ts`) splits this into `clients` (people) ↔ `interests` (deals), with `clientContacts` (multi-channel), `clientAddresses` (multi-address), and a pre-existing `clientMergeLog` table that anticipates merge with undo. The design has been ready; what's missing is (a) a normalization + matching library, (b) the at-create / at-import surfaces that use it, and (c) the migration of the existing 252+35 records.
|
||||
|
||||
### 1.2 Real duplicate patterns observed in the live data
|
||||
|
||||
Sampled 200 of the 252 NocoDB Interests rows. Confirmed duplicate clusters fall into six patterns:
|
||||
|
||||
| Pattern | Example rows | Signature |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **A. Pure double-submit** | Deepak Ramchandani #624/#625; John Lynch #716/#725 | All fields identical; created same day |
|
||||
| **B. Phone format variance** | Howard Wiarda #236/#536 (`574-274-0548` vs `+15742740548`); Christophe Zasso #701/#702 (`0651381036` vs `0033651381036`) | Same email, normalize-equal phone |
|
||||
| **C. Name capitalization** | Nicolas Ruiz #681/#682/#683; Jean-Charles Miege/MIEGE #37/#163; John Farmer/FARMER #35/#161 | Same email or empty; surname case differs |
|
||||
| **D. Name shortening** | Chris vs Christopher Allen #700/#534; Emma c vs Emma Cauchefer #661/#673 | Same email + phone; given-name truncated |
|
||||
| **E. Resubmit with typo** | Christopher Camazou #649/#650 (phone last 4 digits typo); Gianfranco Di Constanzo/Costanzo #585/#336 (surname typo, **different yacht** — should be ONE client + TWO interests) | Score-on-everything-else high, one field has small-edit-distance noise |
|
||||
| **F. Hard cases** | Etiennette Clamouze #188/#717 (same name, different country phone + email); Bruno Joyerot #18 with email belonging to Bruce Hearn #19 (couple sharing contact) | Cannot resolve without a human |
|
||||
|
||||
This dataset will be the fixture for the dedup library's tests — every pattern above must be either auto-detected or flagged for review, and the false-positive bar must be high enough that Pattern F doesn't get force-merged.
|
||||
|
||||
### 1.3 Dirty data inventory
|
||||
|
||||
The migration normalizer must survive these real values from production:
|
||||
|
||||
**Phone fields**: `+1-264-235-8840\r` (with carriage return), `'+1.214.603.4235` (apostrophe + dots), `0677580750/0690511494` (two numbers in one field), `00447956657022` (00 prefix), `+447000000000` (placeholder all-zeros), `+4901637039672` (impossible — stripped 0 + country prefix), various unprefixed local formats, dashed US numbers without country code.
|
||||
|
||||
**Email fields**: mixed case rampant (`Arthur@laser-align.com` vs `arthur@laser-align.com`); ALL-CAPS local parts; trailing whitespace.
|
||||
|
||||
**Name fields**: ALL-CAPS surnames mixed with title-case given names; embedded `\n` and `\r`; double spaces; lowercase-only entries; slash-with-company variants (`Daniel Wainstein / 7 Knots, LLC`, `Bruno Joyerot / SAS TIKI`); placeholder `Mr DADER`, `TBC`.
|
||||
|
||||
**Place of Residence (free text)**: `Saint barthelemy`, `St Barth`, `Saint-Barthélemy` (same place, three forms); `anguilla`, `United States `, `USA`, `Kansas City` (city without country), `Sag Harbor Y` (typo).
|
||||
|
||||
### 1.4 Existing battle-tested algorithm
|
||||
|
||||
`client-portal/server/utils/duplicate-detection.ts` already implements blocking + weighted-rules dedup against this same NocoDB. It runs in production today. We **port it forward** (don't reinvent), then add: soundex/metaphone for surname matching, compounded-confidence when multiple rules match, and negative evidence (same email + different country phone reduces confidence).
|
||||
|
||||
### 1.5 Why the website is no longer the source of new dirty data
|
||||
|
||||
The website forms (`website/components/pn/specific/website/{berths-item,register,form}/form.vue`) use `<v-phone-input>` with a country picker (`prefer-countries: ['US', 'GB', 'DE', 'FR']`) and `[(value) => !!value || 'Phone number is required']` validation. Output is E.164-shaped. The 252 dirty rows are legacy — pre-form-redesign submissions, sales-rep manual entries, and external CSV imports. Future inbound is clean.
|
||||
|
||||
---
|
||||
|
||||
## 2. Approach
|
||||
|
||||
Three artifacts, layered:
|
||||
|
||||
1. **A pure-logic normalization + matching library** at `src/lib/dedup/`. JSX-free, vitest-native (proven pattern: `realtime-invalidation-core.ts`). Tested against the dirty-data fixture corpus drawn from §1.2.
|
||||
2. **Three runtime surfaces** that use the library: at-create suggestion in client/interest forms; interest-level same-berth guard; admin review queue powered by a nightly background scoring job.
|
||||
3. **A one-shot migration script** that pulls NocoDB → normalizes → dedupes → writes new schema → produces a CSV report with auto-merge log + flagged-for-review pile.
|
||||
|
||||
**Configurability via admin settings** (`system_settings` per port) so the team can tune sensitivity without code changes. Defaults err on the safe side — a flagged review is cheaper than a wrongly-merged record.
|
||||
|
||||
**Reversibility**: every merge writes a `client_merge_log` row containing the loser's full pre-state JSON. A 7-day undo window lets a wrong merge be reversed without engineering involvement. After 7 days the snapshot is purged for GDPR; merges become permanent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Normalization library
|
||||
|
||||
Lives at `src/lib/dedup/normalize.ts`. Pure functions, no DB, vitest-tested. Used by the dedup algorithm AND by all create-paths so what gets stored is already normalized.
|
||||
|
||||
### 3.1 `normalizeName(raw: string)`
|
||||
|
||||
```ts
|
||||
export function normalizeName(raw: string): {
|
||||
display: string; // human-readable, kept for UI
|
||||
normalized: string; // for matching
|
||||
surnameToken?: string; // for surname-based blocking
|
||||
};
|
||||
```
|
||||
|
||||
- Trim leading/trailing whitespace
|
||||
- Replace `\r`, `\n`, tabs with single space
|
||||
- Collapse consecutive whitespace to single space
|
||||
- Smart title-case: keep particles (`van`, `de`, `del`, `O'`, `di`, `le`, `da`) lowercase except as first token
|
||||
- `display` preserves user's intent (slash-with-company stays intact)
|
||||
- `normalized` is `display.toLowerCase()` for comparison
|
||||
- `surnameToken` is the last non-particle token for blocking
|
||||
|
||||
### 3.2 `normalizeEmail(raw: string)`
|
||||
|
||||
```ts
|
||||
export function normalizeEmail(raw: string): string | null;
|
||||
```
|
||||
|
||||
- Trim + lowercase
|
||||
- Validate via `zod.email()` schema
|
||||
- Returns `null` for empty / invalid (caller decides what to do)
|
||||
- **Does NOT strip plus-aliases** (`user+tag@domain.com`) — both intentional (real distinct addresses) and malicious-prevention apply. Compare by full localpart.
|
||||
|
||||
### 3.3 `normalizePhone(raw: string, defaultCountry: string)`
|
||||
|
||||
```ts
|
||||
export function normalizePhone(
|
||||
raw: string,
|
||||
defaultCountry: string,
|
||||
): {
|
||||
e164: string | null; // canonical, e.g. '+15742740548'
|
||||
country: string | null; // ISO-3166-1 alpha-2
|
||||
display: string | null; // user-facing pretty
|
||||
flagged?: 'multi_number' | 'placeholder' | 'unparseable';
|
||||
} | null;
|
||||
```
|
||||
|
||||
Pipeline:
|
||||
|
||||
1. Strip `\r`, `\n`, tabs, single quotes, dots, dashes, parens, spaces
|
||||
2. If contains `/` or `;` or `,` → flag `multi_number`, take first segment
|
||||
3. If matches `+\d{2}0+$` (e.g., `+447000000000`) → flag `placeholder`, return null
|
||||
4. If starts with `00` → replace with `+`
|
||||
5. If starts with `+` → parse as E.164
|
||||
6. Else if `defaultCountry` provided → parse against that country
|
||||
7. Else return null (caller's problem)
|
||||
|
||||
Backed by `libphonenumber-js` (already in deps via `tests/integration/factories.ts` usage if not, will add). The hostile cases above all need explicit handling — naïve regex won't survive.
|
||||
|
||||
### 3.4 `resolveCountry(text: string)`
|
||||
|
||||
```ts
|
||||
export function resolveCountry(text: string): {
|
||||
iso: string | null; // ISO-3166-1 alpha-2
|
||||
confidence: 'exact' | 'fuzzy' | 'city' | null;
|
||||
};
|
||||
```
|
||||
|
||||
Reuses `src/lib/i18n/countries.ts`. Pipeline:
|
||||
|
||||
1. Lowercase + strip diacritics
|
||||
2. Exact match against country names (any locale we ship)
|
||||
3. Fuzzy match (Levenshtein ≤ 2 against canonical English names)
|
||||
4. City fallback — small in-package mapping for high-frequency cities seen in legacy data (`Sag Harbor → US`, `Kansas City → US`, `St Barth → BL`, etc.). Order: exact → city → fuzzy.
|
||||
|
||||
The mapping is opinionated and small (~30 entries covering the actual values seen in the 252-row dataset). Anything that fails to resolve returns `null` and lands in the migration's flagged pile.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dedup algorithm
|
||||
|
||||
Lives at `src/lib/dedup/find-matches.ts`. Pure function. Vitest-tested against the §1.2 cluster fixtures.
|
||||
|
||||
### 4.1 Public API
|
||||
|
||||
```ts
|
||||
export interface MatchCandidate {
|
||||
id: string;
|
||||
fullName: string | null;
|
||||
emails: string[]; // already normalized
|
||||
phonesE164: string[]; // already normalized E.164
|
||||
countryIso: string | null;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
candidate: MatchCandidate;
|
||||
score: number; // 0–100
|
||||
reasons: string[]; // human-readable, e.g. ["email match", "phone match"]
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export function findClientMatches(
|
||||
input: MatchCandidate,
|
||||
pool: MatchCandidate[],
|
||||
thresholds: DedupThresholds,
|
||||
): MatchResult[];
|
||||
```
|
||||
|
||||
### 4.2 Scoring rules (compound)
|
||||
|
||||
Each rule produces a score addition. **Compounding**: when two strong rules match (e.g., email AND phone), the result is ~95+ rather than max(50, 50). Negative evidence subtracts.
|
||||
|
||||
| Rule | Score | Notes |
|
||||
| --------------------------------------------------------------- | ----- | ------------------------------------------------------ |
|
||||
| Exact email match (case-insensitive, normalized) | +60 | One match suffices |
|
||||
| Exact phone E.164 match (≥ 8 significant digits) | +50 | Excludes placeholder all-zeros |
|
||||
| Exact normalized full-name match | +20 | Many "John Smith"s exist |
|
||||
| Surname soundex match + given-name fuzzy match (Lev ≤ 1) | +15 | Catches `Constanzo/Costanzo`, `Christophe/Christopher` |
|
||||
| Same address (normalized fuzzy ≥ 0.8) | +10 | Bonus signal |
|
||||
| **Negative**: Same email but different country code on phone | −15 | Suggests spouse / coworker / shared inbox |
|
||||
| **Negative**: Same name but DIFFERENT email AND DIFFERENT phone | −20 | Two distinct people with the same name |
|
||||
|
||||
### 4.3 Confidence tiers (post-compound)
|
||||
|
||||
- **score ≥ 90 — `high`** — email AND phone match, or email + name + address. Block-create suggest "Use existing." Auto-link on public-form submit by default.
|
||||
- **score 50–89 — `medium`** — single strong signal (email or phone alone), or email + same-name + different country (Etiennette case). Soft-warn but allow.
|
||||
- **score < 50 — `low`** — weak signals only. Don't surface in UI; only relevant in background-job review queue.
|
||||
|
||||
### 4.4 Blocking strategy
|
||||
|
||||
For O(n) scan over a pool of N existing clients, build three lookup maps once per scan:
|
||||
|
||||
- `byEmail: Map<string, MatchCandidate[]>` — keyed by normalized email
|
||||
- `byPhoneE164: Map<string, MatchCandidate[]>` — keyed by E.164
|
||||
- `bySurnameToken: Map<string, MatchCandidate[]>` — keyed by `normalizeName(...).surnameToken`
|
||||
|
||||
For an incoming `MatchCandidate`, the candidate set to compare is the union of pool entries reachable through any of its emails/phones/surname-token. Typically 0–5 candidates per query, regardless of N.
|
||||
|
||||
### 4.5 Performance budget
|
||||
|
||||
For migration: 252 rows compared pairwise once. ~30k comparisons after blocking — a few seconds.
|
||||
|
||||
For runtime at-create: incoming candidate against existing pool of N clients per port. Expected pool size at maturity: 1k–10k. With blocking: <10 comparisons, <1ms target. No DB query needed beyond the initial pool fetch (which itself uses the indexed columns).
|
||||
|
||||
For background nightly job: full pairwise within port, blocked. 10k clients → ~50k pairwise checks per port → <30s. Fine for a nightly cron.
|
||||
|
||||
---
|
||||
|
||||
## 5. Configurable thresholds (admin settings)
|
||||
|
||||
New rows in `system_settings` per port. Default values err safe (more confirmation, less auto-action).
|
||||
|
||||
| Key | Default | Effect |
|
||||
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `dedup_block_create_threshold` | `90` | Score above which the client-create form interrupts: "Use existing client?" |
|
||||
| `dedup_soft_warn_threshold` | `50` | Score above which a soft-warn panel surfaces below the form |
|
||||
| `dedup_review_queue_threshold` | `40` | Background job lands pairs ≥ this score in `/admin/duplicates` |
|
||||
| `dedup_public_form_auto_link` | `true` | When a public-form submission scores ≥ block-threshold against existing client, attach the new interest to that client without prompting. **Safe**: no merge, just attaching a deal. |
|
||||
| `dedup_auto_merge_threshold` | `null` (disabled) | If non-null, merges happen automatically at this threshold without human confirmation. Recommend leaving null until the team is comfortable; `95` is a reasonable cautious value. |
|
||||
| `dedup_undo_window_days` | `7` | How long the loser's pre-state JSON is retained for merge-undo. After this, the snapshot is purged (GDPR) and merges are permanent. |
|
||||
|
||||
Each setting is a row in `system_settings`. UI surface in `/[portSlug]/admin/dedup` (a new admin page) with an "Advanced" toggle to expose the thresholds and brief explanations.
|
||||
|
||||
If the sales team complains the safer mode is too click-heavy, an admin flips `dedup_auto_merge_threshold` to `95` without any code change.
|
||||
|
||||
---
|
||||
|
||||
## 6. Merge service contract
|
||||
|
||||
### 6.1 Data flow
|
||||
|
||||
`mergeClients(winnerId, loserId, fieldChoices, ctx)` does, in a single transaction:
|
||||
|
||||
1. **Snapshot loser** — full row + all attached `clientContacts`, `clientAddresses`, `clientNotes`, `clientTags`, plus a count of dependent rows about to be moved (interests, yacht-memberships, etc.). Stored as `mergeDetails` JSONB in `clientMergeLog`.
|
||||
2. **Reattach** — every row pointing at `loserId` updates to point at `winnerId`:
|
||||
- `interests.clientId`
|
||||
- `clientContacts.clientId` — with conflict handling: if winner already has the same email, keep winner's; flag the duplicate for the user
|
||||
- `clientAddresses.clientId` — same conflict handling
|
||||
- `clientNotes.clientId` — preserve `authorId` + `createdAt` (never overwrite)
|
||||
- `clientTags.clientId`
|
||||
- `clientYachtMembership.clientId` (or whatever the table is called)
|
||||
- `auditLogs.entityId` — annotate, don't move (audit truth)
|
||||
3. **Apply fieldChoices** — for each field where the user picked the loser's value, copy that into the winner row.
|
||||
4. **Soft-archive loser** — `loser.archivedAt = now()`, `loser.mergedIntoClientId = winnerId`. Row stays in DB so the merge is reversible.
|
||||
5. **Write `clientMergeLog`** — `{ winnerId, loserId, mergedBy, mergedAt, mergeDetails: <snapshot>, fieldChoices }`.
|
||||
6. **Audit log** — top-level `auditLogs` row: `{ action: 'merge', entityType: 'client', entityId: winnerId, metadata: { loserId, score, reasons } }`.
|
||||
|
||||
### 6.2 Schema additions (migration)
|
||||
|
||||
`clients` table gets a new column:
|
||||
|
||||
```ts
|
||||
mergedIntoClientId: text('merged_into_client_id').references(() => clients.id),
|
||||
```
|
||||
|
||||
The existing `clientMergeLog` table is reused. Add a partial index for the undo-window query:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_cml_recent ON client_merge_log (port_id, created_at DESC) WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
```
|
||||
|
||||
A daily maintenance job (using the existing `maintenance-cleanup.test.ts` infrastructure) purges `mergeDetails` JSONB older than `dedup_undo_window_days` setting.
|
||||
|
||||
### 6.3 Undo
|
||||
|
||||
`unmergeClients(mergeLogId, ctx)`:
|
||||
|
||||
1. Within the undo window, look up the snapshot
|
||||
2. Restore loser: clear `archivedAt`, `mergedIntoClientId`
|
||||
3. Restore loser's contacts/addresses/notes/tags from snapshot
|
||||
4. Detach reattached rows: `interests` etc. that were touching `winnerId` and originally belonged to loser go back. The snapshot stores the original `(rowType, rowId)` list explicitly so this is deterministic.
|
||||
5. Mark log row `undoneAt = now()`, `undoneBy = userId`
|
||||
|
||||
After 7 days the snapshot is gone and unmerge returns `410 Gone`.
|
||||
|
||||
### 6.4 Concurrency
|
||||
|
||||
Both merge and unmerge wrap in a single transaction with `SELECT … FOR UPDATE` on `clients.id` of both winner and loser. A second merge attempt against the same loser sees `mergedIntoClientId` already set and refuses (clear error: "Already merged into …").
|
||||
|
||||
---
|
||||
|
||||
## 7. Runtime surfaces
|
||||
|
||||
### 7.1 Layer 1 — At-create suggestion
|
||||
|
||||
In `ClientForm` (and the public `register` form once that hits the new system):
|
||||
|
||||
- Debounced 300ms after email or phone field changes
|
||||
- Calls `findClientMatches` against current port's clients
|
||||
- Renders top-1 match if score ≥ `dedup_soft_warn_threshold`:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ This looks like an existing client │
|
||||
│ ML Marcus Laurent │
|
||||
│ marcus@… +33 6 12 34 56 78 │
|
||||
│ 2 interests · last 9d ago │
|
||||
│ [ Use this client ] [ Create new ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
- "Use this client" → form switches to "create new interest under existing client" mode (preserves whatever other fields the user typed)
|
||||
- "Create new" → audit-log `dedup_override` with the candidate's id and reasons (so we have data on false positives)
|
||||
|
||||
### 7.2 Layer 2 — Interest-level same-berth guard
|
||||
|
||||
Cheap one-liner in `createInterest` service:
|
||||
|
||||
- Check `(clientId, berthId)` against existing non-archived interests
|
||||
- If hit, throw `BerthDuplicateError` with the existing interest details
|
||||
- UI catches and prompts: "Update existing or create separate?"
|
||||
|
||||
This is NOT the same as client-level dedup. Same client legitimately can pursue the same berth a second time after it falls through. But the prompt-before-create catches the accidental double-submit case.
|
||||
|
||||
### 7.3 Layer 3 — Background scoring + review queue
|
||||
|
||||
- A nightly cron (using existing BullMQ infrastructure — search for `scheduled-tasks` in repo) runs `findClientMatches` over each port's full client pool
|
||||
- Pairs scoring ≥ `dedup_review_queue_threshold` land in a `client_merge_candidates` table:
|
||||
```ts
|
||||
export const clientMergeCandidates = pgTable('client_merge_candidates', {
|
||||
id: text('id').primaryKey()...,
|
||||
portId: text('port_id').notNull()...,
|
||||
clientAId: text('client_a_id').notNull()...,
|
||||
clientBId: text('client_b_id').notNull()...,
|
||||
score: integer('score').notNull(),
|
||||
reasons: jsonb('reasons').notNull(),
|
||||
status: text('status').notNull().default('pending'), // pending | dismissed | merged
|
||||
createdAt: timestamp('created_at')...,
|
||||
resolvedAt: timestamp('resolved_at'),
|
||||
resolvedBy: text('resolved_by'),
|
||||
})
|
||||
```
|
||||
- `/[portSlug]/admin/duplicates` lists pending candidates sorted by score desc, with `[Review →]` opening a side-by-side merge dialog
|
||||
- Dismissing a candidate marks it `status=dismissed` so the job doesn't re-surface the same pair tomorrow (a future score increase re-creates it).
|
||||
|
||||
---
|
||||
|
||||
## 8. NocoDB → new system field mapping
|
||||
|
||||
This is the explicit mapping the migration script applies. One NocoDB Interest row produces multiple new rows.
|
||||
|
||||
### 8.1 Top-level transform
|
||||
|
||||
```
|
||||
NocoDB Interests row
|
||||
─→ 0–1 client (deduped against existing pool)
|
||||
─→ 0–1 client_address
|
||||
─→ 0–2 client_contacts (email, phone)
|
||||
─→ exactly 1 interest
|
||||
─→ 0–1 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
|
||||
─→ 0–1 document (when documensoID present)
|
||||
```
|
||||
|
||||
### 8.2 Field map
|
||||
|
||||
| NocoDB field | Target | Transform |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `Full Name` | `clients.fullName` | `normalizeName().display` |
|
||||
| `Email Address` | `clientContacts(channel='email', value=...)` | `normalizeEmail()` |
|
||||
| `Phone Number` | `clientContacts(channel='phone', valueE164=..., valueCountry=...)` | `normalizePhone(raw, defaultCountry)` |
|
||||
| `Address` | `clientAddresses.streetAddress` (LongText preserved) | trim |
|
||||
| `Place of Residence` | `clientAddresses.countryIso` AND `clients.nationalityIso` | `resolveCountry()` |
|
||||
| `Contact Method Preferred` | `clients.preferredContactMethod` | lowercase, mapped: Email→email, Phone→phone |
|
||||
| `Source` | `clients.source` | mapped: portal→website, Form→website, External→manual; null → manual |
|
||||
| `Date Added` | `interests.createdAt` (fallback to NocoDB `Created At` then now) | parse: try `DD-MM-YYYY`, then `YYYY-MM-DD`, then ISO |
|
||||
| `Sales Process Level` | `interests.pipelineStage` | see §8.3 |
|
||||
| `Lead Category` | `interests.leadCategory` | General→general_interest, Friends and Family→general_interest with tag |
|
||||
| `Berth` (FK) | `interests.berthId` | resolve via `Berths` table by `Mooring Number` |
|
||||
| `Berth Size Desired` | `interests.notes` (appended) | preserve |
|
||||
| `Yacht Name`, `Length`, `Width`, `Depth` | `yachts.name`, `lengthM`, `widthM`, `draughtM` | skip if name in {`TBC`, `Na`, ``, null}; ft→m via `\* 0.3048` |
|
||||
| `EOI Status` | `interests.eoiStatus` | Awaiting Further Details→pending; Waiting for Signatures→sent; Signed→signed |
|
||||
| `Deposit 10% Status` | `interests.depositStatus` | Pending→pending; Received→received |
|
||||
| `Contract Status` | `interests.contractStatus` | Pending→pending; 40% Received→partial; Complete→complete |
|
||||
| `EOI Time Sent` | `interests.dateEoiSent` | parse |
|
||||
| `clientSignTime` / `developerSignTime` / `all_signed_notified_at` | `interests.dateEoiSigned` (use latest) | parse |
|
||||
| `Time LOI Sent` | `interests.dateContractSent` | parse |
|
||||
| `Internal Notes` + `Extra Comments` | `clientNotes` (one row, system author) | concatenate with section markers |
|
||||
| `documensoID` | `documents.documensoId` (when present, type='eoi') | preserve |
|
||||
| `Signature Link Client/CC/Developer`, `EmbeddedSignature*` | `documents.signers[]` | one row per non-null signer |
|
||||
| `reminder_enabled`, `last_reminder_sent`, etc. | `interests.reminderEnabled`, `interests.reminderLastFired` | parse, default true |
|
||||
|
||||
### 8.3 Sales-stage mapping (8 → 9)
|
||||
|
||||
| NocoDB | New (PIPELINE_STAGES) |
|
||||
| ------------------------------- | ------------------------------------------------------------------------ |
|
||||
| General Qualified Interest | `open` |
|
||||
| Specific Qualified Interest | `details_sent` |
|
||||
| EOI and NDA Sent | `eoi_sent` |
|
||||
| Signed EOI and NDA | `eoi_signed` |
|
||||
| Made Reservation | `deposit_10pct` |
|
||||
| Contract Negotiation | `contract_sent` |
|
||||
| Contract Negotiations Finalized | `contract_sent` (with audit-note: legacy "negotiations finalized") |
|
||||
| Contract Signed | `contract_signed` (or `completed` when deposit + contract both complete) |
|
||||
|
||||
### 8.4 Other tables
|
||||
|
||||
- **Residential Interests** (35 rows) — same shape as Interests but maps to `residentialClients` + `residentialInterests`. Smaller and cleaner. Same dedup runs within this pool independently.
|
||||
- **Website - Interest Submissions** (64 rows) — these are **inbound capture, not yet a client**. Treat as if each row is a fresh public-form submission today: run dedup against the migrated client pool. Auto-link if `dedup_public_form_auto_link` setting allows.
|
||||
- **Website - Contact Form Submissions** (47 rows) — sparse data (just name + email + interest type). Skip migration; export as CSV for manual triage. Not the source of truth for any deal.
|
||||
- **Website - Berth EOI Details Supplements** (1 row) — single record, preserved as a one-off attached to the matching Interest.
|
||||
- **Newsletter Sending** (69 rows) — out of scope; that's a marketing surface, not CRM.
|
||||
- **Interests Backup, Interests copy** — historical artifacts. Skipped by default. A `--include-backups` flag attaches them as audit-note entries on the corresponding live Interest if the user wants the history.
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration script
|
||||
|
||||
Located at `scripts/migrate-from-nocodb.ts`. Idempotent: safe to re-run. Three main flags:
|
||||
|
||||
```
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug X]
|
||||
Pulls everything, transforms, runs dedup, writes CSV report to .migration/<timestamp>/. No DB writes.
|
||||
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<timestamp>/
|
||||
Reads the report, performs the writes the dry-run promised. Refuses if the source data has changed since the report was generated (hash mismatch).
|
||||
|
||||
$ pnpm tsx scripts/migrate-from-nocodb.ts --rollback --apply-id <id>
|
||||
Reads the apply log, undoes the writes (only valid within the undo window).
|
||||
```
|
||||
|
||||
Reuses the `client-portal/server/utils/nocodb.ts` adapter for the NocoDB API client (no need to rebuild). Writes to the new system via Drizzle (re-using the existing services like `createClient`, `createInterest`, etc., so all the same validation runs).
|
||||
|
||||
### 9.1 Dry-run report format
|
||||
|
||||
`.migration/<timestamp>/report.csv`:
|
||||
|
||||
```csv
|
||||
op,reason,nocodb_row_id,target_table,target_value,confidence,manual_review_required
|
||||
create_client,new,624,clients.fullName,Deepak Ramchandani,N/A,false
|
||||
create_contact,new,624,clientContacts.email,dannyrams8888@gmail.com,N/A,false
|
||||
create_contact,new,624,clientContacts.phone,+17215868888,N/A,false
|
||||
create_interest,new,624,interests.berthId,a1b2c3...,N/A,false
|
||||
auto_link,score=98 (email+phone),625,clients.id,<existing client UUID from row 624>,high,false
|
||||
flag_for_review,score=72 (same name diff country),188,client.id,<existing client UUID from row 717>,medium,true
|
||||
country_unresolved,fallback to AI (port country),198,clientAddresses.countryIso,AI,low,true
|
||||
phone_unparseable,placeholder all-zeros,641,clientContacts.phone,<skipped>,N/A,true
|
||||
```
|
||||
|
||||
Plus `.migration/<timestamp>/summary.md`:
|
||||
|
||||
```
|
||||
# Migration Dry-Run — 2026-05-03 14:23 UTC
|
||||
|
||||
NocoDB: 252 Interests + 35 Residences + 64 Website Submissions
|
||||
Outcome: 198 clients, 287 interests (incl. residences), 91 yachts, 412 contacts
|
||||
|
||||
Auto-linked (high confidence, no human action needed):
|
||||
- Nicolas Ruiz: rows 681,682,683 → 1 client + 3 interests
|
||||
- John Lynch: rows 716,725 → 1 client + 2 interests
|
||||
- Deepak Ramchandani: rows 624,625 → 1 client + 2 interests
|
||||
- [12 more]
|
||||
|
||||
Flagged for manual review (medium confidence):
|
||||
- Etiennette Clamouze (rows 188,717): same name, different country phone + email
|
||||
- Bruno Joyerot #18 + Bruce Hearn #19: shared household contact
|
||||
- [4 more]
|
||||
|
||||
Country resolution failed for 7 rows. All defaulted to port country (AI). Review:
|
||||
- Row 239: "Sag Harbor Y" → AI (likely US)
|
||||
- [6 more]
|
||||
|
||||
Phone parsing failed for 3 rows. All flagged, no contact created:
|
||||
- Row 178: empty
|
||||
- Row 641: placeholder "+447000000000"
|
||||
- Row 175: empty
|
||||
|
||||
Run `--apply` to commit these changes.
|
||||
```
|
||||
|
||||
### 9.2 Apply phase
|
||||
|
||||
`--apply` reads the report, re-fetches the source rows (via NocoDB MCP / API), recomputes the hash, fails fast if NocoDB changed since dry-run. Then performs the writes within a single PostgreSQL transaction per port (commit at end). On any error mid-transaction, full rollback.
|
||||
|
||||
After successful apply, an `apply_id` is generated and an audit-log row written. The `apply_id` is the handle used for `--rollback`.
|
||||
|
||||
### 9.3 Idempotency
|
||||
|
||||
The script tracks NocoDB row IDs in a `migration_source_links` table:
|
||||
|
||||
```ts
|
||||
export const migrationSourceLinks = pgTable('migration_source_links', {
|
||||
id: text('id').primaryKey()...,
|
||||
sourceSystem: text('source_system').notNull(), // 'nocodb_interests' | 'nocodb_residences' | …
|
||||
sourceId: text('source_id').notNull(), // NocoDB row id as string
|
||||
targetEntityType: text('target_entity_type').notNull(), // client | interest | yacht | …
|
||||
targetEntityId: text('target_entity_id').notNull(),
|
||||
appliedAt: timestamp('applied_at')...,
|
||||
appliedBy: text('applied_by'),
|
||||
}, (table) => [
|
||||
uniqueIndex('idx_msl_source').on(table.sourceSystem, table.sourceId, table.targetEntityType),
|
||||
]);
|
||||
```
|
||||
|
||||
Re-running `--apply` against the same report skips rows already in this table. Useful for partial-failure resumption.
|
||||
|
||||
---
|
||||
|
||||
## 10. Test plan
|
||||
|
||||
### 10.1 Library-level (vitest unit)
|
||||
|
||||
- `tests/unit/dedup/normalize.test.ts` — every dirty-data pattern from §1.3 has a fixture asserting the expected normalized output.
|
||||
- `tests/unit/dedup/find-matches.test.ts` — every duplicate cluster from §1.2 has a fixture asserting score + confidence tier. Hard cases (Pattern F) assert "medium" not "high" — false-positive guard.
|
||||
|
||||
### 10.2 Service-level (vitest integration)
|
||||
|
||||
- `tests/integration/dedup/client-merge.test.ts` — merge service exercised: full reattach, clientMergeLog written, undo within window restores, undo after window returns 410, concurrent merge of same loser fails the second.
|
||||
- `tests/integration/dedup/at-create-suggestion.test.ts` — `findClientMatches` against a seeded pool returns expected matches + reasons.
|
||||
|
||||
### 10.3 Migration script (vitest integration with NocoDB mock)
|
||||
|
||||
- `tests/integration/dedup/migration-dry-run.test.ts` — feed the script a fixture NocoDB dump (the 252 rows, frozen as a JSON snapshot in fixtures), assert the resulting CSV matches a golden file. Catch any future regression in the transform pipeline.
|
||||
- `tests/integration/dedup/migration-apply.test.ts` — apply the dry-run output to a clean test DB, assert all expected rows exist, assert idempotency (re-apply is a no-op).
|
||||
|
||||
### 10.4 E2E (Playwright)
|
||||
|
||||
- `tests/e2e/smoke/30-dedup-create.spec.ts` — type into ClientForm with an email matching seeded client; assert suggestion card appears; click "Use this client"; assert form switches to interest-create mode.
|
||||
- `tests/e2e/smoke/31-admin-duplicates.spec.ts` — admin views review queue, opens a candidate, side-by-side merge UI works, merge succeeds, undo within window works.
|
||||
|
||||
---
|
||||
|
||||
## 11. Rollback plan
|
||||
|
||||
Three layers of safety, ordered by reversibility:
|
||||
|
||||
1. **Per-merge undo** — admin clicks Undo on a wrongly-merged pair, system rolls back from `clientMergeLog` snapshot. 7-day window. No engineering needed.
|
||||
2. **Migration `--rollback` flag** — entire migration apply is reversed via the `apply_id` and `migration_source_links` table. Useful in the first 24h after `--apply`. Engineering-supervised.
|
||||
3. **DB restore from backup** — the existing `docs/ops/backup-runbook.md` covers this. Last resort if both above are blocked.
|
||||
|
||||
Pre-migration, take a hot backup of the new DB (`pg_dump`). Pre-merge in production (before any human-facing surface ships), the `dedup_auto_merge_threshold` defaults to `null` so no automatic merges happen — every merge is human-confirmed.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open items
|
||||
|
||||
- **Soundex vs metaphone** — Soundex is simpler but English-leaning. Metaphone handles non-English surnames better (the dataset has French, German, Italian, Slavic names). Default to metaphone via the `natural` package; revisit if it adds significant install size.
|
||||
- **Cross-port dedup** — not in scope. Each port's clients are deduped within that port. A future "shared address book" feature would need its own design.
|
||||
- **Profile photo / face match** — out of scope.
|
||||
- **AI-assisted match resolution** — out of scope. The Layer-3 review queue is human-only.
|
||||
|
||||
---
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
P1 (this design's library) → P2 (runtime surfaces) → P3 (migration). Each is a separate plan / PR.
|
||||
|
||||
**P1 deliverables**: `src/lib/dedup/{normalize,find-matches}.ts` + tests. No UI changes. No DB changes (except indexed lookups added to existing `clientContacts`). ~1.5 days.
|
||||
|
||||
**P2 deliverables**: at-create suggestion in `ClientForm` + interest-level guard in `createInterest` service + admin settings UI for thresholds + `clientMergeCandidates` table + nightly job + admin review queue page + merge service + side-by-side merge UI. ~5–7 days.
|
||||
|
||||
**P3 deliverables**: `scripts/migrate-from-nocodb.ts` + `migration_source_links` table + dry-run + apply + rollback. CSV report format frozen against fixture. ~3 days, including fixture creation from the live NocoDB snapshot.
|
||||
|
||||
Total: ~10–12 engineering days from approval. Can be split across three PRs landing independently — each is testable in isolation and the runtime surfaces (P2) work even without P3 being run.
|
||||
@@ -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,64 @@
|
||||
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,
|
||||
{
|
||||
// Scope the typescript-eslint rule overrides to TS/TSX files. Without
|
||||
// the `files` filter, eslint flat-config attempts to apply these
|
||||
// rules to every walked file (including root-level JS / mjs / json
|
||||
// configs) and fails because the typescript-eslint plugin only
|
||||
// registers itself for TS/TSX. Surfaced 2026-05-14 when CI's
|
||||
// `pnpm lint` command ran across the whole repo root.
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
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. Also
|
||||
// relax unused-vars to warn (destructured-but-unused helpers are
|
||||
// common in setup/teardown patterns).
|
||||
files: ['tests/**/*.ts', 'tests/**/*.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'client-portal/**',
|
||||
'next-env.d.ts',
|
||||
// Agent worktree artifacts — not part of the canonical tree.
|
||||
'.claude/**',
|
||||
// Build output + Next generated types
|
||||
'.next/**',
|
||||
'dist/**',
|
||||
// Other sub-projects with their own toolchains
|
||||
'website/**',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
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/dev/types/routes.d.ts';
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
147
next.config.ts
147
next.config.ts
@@ -1,7 +1,100 @@
|
||||
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
|
||||
* X-Content-Type-Options — the app was open to clickjacking + MIME
|
||||
* sniffing.
|
||||
*
|
||||
* CSP notes:
|
||||
* - 'unsafe-inline' on style-src is required by Tailwind's runtime
|
||||
* style injection and Radix; revisit when Tailwind v4 ships a
|
||||
* nonce story.
|
||||
* - 'unsafe-eval' on script-src is dev-only — Next dev uses eval for
|
||||
* HMR. Production drops it.
|
||||
* - connect-src allows ws/wss for Socket.IO and https: for outgoing
|
||||
* fetches; tighten in prod via per-port branding URLs once we move
|
||||
* the s3 image references into a known allowlist.
|
||||
* - img-src https: is wide because port branding pulls from
|
||||
* s3.portnimara.com plus per-port image URLs configured at runtime.
|
||||
*/
|
||||
// Dev-only allow-list: react-grab (the in-page click-to-source devtool)
|
||||
// is fetched from unpkg, so script/style/connect must allow it. Strip
|
||||
// these entries in prod via the conditional below.
|
||||
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}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
`connect-src 'self' ws: wss: https:${devConnectHosts}`,
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join('; ');
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: 'Content-Security-Policy', value: csp },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' },
|
||||
...(isProd
|
||||
? [{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }]
|
||||
: []),
|
||||
];
|
||||
|
||||
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',
|
||||
@@ -11,19 +104,65 @@ 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 [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
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)));
|
||||
|
||||
@@ -4,6 +4,10 @@ proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
# Defense-in-depth for CVE-2025-29927: strip the header attackers use to
|
||||
# skip Next.js middleware. Patched in next>=15.2.3, but neutralizing the
|
||||
# input at the edge means a future regression cannot reopen the bypass.
|
||||
proxy_set_header X-Middleware-Subrequest "";
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
|
||||
182
package.json
182
package.json
@@ -2,39 +2,48 @@
|
||||
"name": "port-nimara-crm",
|
||||
"version": "0.1.0",
|
||||
"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",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"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",
|
||||
"test:e2e:destructive": "playwright test --project=destructive",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@pdfme/common": "^5.5.8",
|
||||
"@pdfme/generator": "^5.5.8",
|
||||
"@pdfme/schemas": "^5.5.8",
|
||||
"@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",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
@@ -48,73 +57,122 @@
|
||||
"@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.62.0",
|
||||
"@tanstack/react-query-devtools": "^5.62.0",
|
||||
"@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",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"archiver": "^7.0.1",
|
||||
"better-auth": "^1.2.0",
|
||||
"bullmq": "^5.25.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"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.38.0",
|
||||
"imapflow": "^1.2.13",
|
||||
"ioredis": "^5.4.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",
|
||||
"jose": "^6.2.1",
|
||||
"libphonenumber-js": "^1.12.42",
|
||||
"lucide-react": "^0.460.0",
|
||||
"mailparser": "^3.9.4",
|
||||
"minio": "^8.0.0",
|
||||
"next": "15.1.0",
|
||||
"next-themes": "^0.4.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"openai": "^6.27.0",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"jose": "^6.2.3",
|
||||
"libphonenumber-js": "^1.13.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mailparser": "^3.9.8",
|
||||
"minio": "^8.0.7",
|
||||
"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",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postgres": "^3.4.0",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"recharts": "^3.8.0",
|
||||
"socket.io": "^4.8.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"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": "^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",
|
||||
"svgo": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"zod": "^3.24.0",
|
||||
"zustand": "^5.0.0"
|
||||
"ts-pattern": "^5.9.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unpdf": "^1.6.2",
|
||||
"vaul": "^1.1.2",
|
||||
"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.58.2",
|
||||
"@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": "^22.0.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "15.1.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^15.2.0",
|
||||
"postcss": "^8.4.0",
|
||||
"prettier": "^3.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.0"
|
||||
"@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",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.4",
|
||||
"postcss": "^8.5.14",
|
||||
"prettier": "^3.8.3",
|
||||
"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.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "8.0.5",
|
||||
"esbuild": ">=0.25.0",
|
||||
"postcss": ">=8.5.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,24 @@ export default defineConfig({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Mobile / tablet audit — visits every page in headed Chromium at iPhone
|
||||
// viewports (portrait), screenshots full-page to .audit/mobile/<viewport>/,
|
||||
// and writes an index.md. Depends on `setup` for seeded admin + port-role.
|
||||
name: 'mobile-audit',
|
||||
testMatch: /audit\/mobile\.spec\.ts/,
|
||||
dependencies: ['setup'],
|
||||
// Single test walks 4 viewports × ~45 routes sequentially with slowMo;
|
||||
// 30 min headroom keeps us well under the wall-clock cost.
|
||||
timeout: 1_800_000,
|
||||
use: {
|
||||
headless: false,
|
||||
launchOptions: { slowMo: 200 },
|
||||
screenshot: 'off',
|
||||
video: 'off',
|
||||
trace: 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Don't start the dev server — we expect it to already be running
|
||||
|
||||
10966
pnpm-lock.yaml
generated
10966
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': {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 654 B |
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 688 B |
BIN
public/icon-512-maskable.png
Normal file
BIN
public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
30
public/manifest.json
Normal file
30
public/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Port Nimara CRM",
|
||||
"short_name": "Port Nimara",
|
||||
"description": "Marina/port management CRM",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f2f2f2",
|
||||
"theme_color": "#0f172a",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
135
scripts/backfill-legacy-lead-source.ts
Normal file
135
scripts/backfill-legacy-lead-source.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* One-shot: backfill `interests.source` for legacy NocoDB-imported rows.
|
||||
*
|
||||
* Why this exists: the legacy NocoDB Interests table left the `Source`
|
||||
* column null for ~95 % of rows. The migration mapped null → null, so the
|
||||
* Lead Source Attribution chart shows them as "Unspecified". Per the
|
||||
* operator's best knowledge, almost all of those legacy rows came in
|
||||
* through the website (web form / portal) — the few that didn't are the
|
||||
* ones that already carry an explicit `Source` value (Form / portal /
|
||||
* External). Defaulting null → 'website' is therefore the closest
|
||||
* truth we can reconstruct without per-row sales notes review.
|
||||
*
|
||||
* Idempotent: only updates rows where `source IS NULL` AND the row has a
|
||||
* `migration_source_links` entry tying it back to the legacy NocoDB import,
|
||||
* so net-new manually-created interests with null source aren't touched.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug port-nimara [--dry-run]
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { eq, and, isNull, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { migrationSourceLinks } from '@/lib/db/schema/migration';
|
||||
|
||||
interface CliArgs {
|
||||
portSlug: string | null;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = { portSlug: null, dryRun: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||
else if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
console.log(
|
||||
'Usage: pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug <slug> [--dry-run]',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
if (!args.portSlug) {
|
||||
console.error('Missing required --port-slug');
|
||||
process.exit(1);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const [port] = await db
|
||||
.select({ id: ports.id, name: ports.name })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, args.portSlug!))
|
||||
.limit(1);
|
||||
if (!port) {
|
||||
console.error(`No port found with slug "${args.portSlug}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[backfill] target: ${port.name} (${port.id})`);
|
||||
|
||||
// Pull every interest id this port owns that has a NULL source.
|
||||
const candidateInterests = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, port.id), isNull(interests.source)));
|
||||
|
||||
console.log(`[backfill] interests with NULL source in this port: ${candidateInterests.length}`);
|
||||
|
||||
if (candidateInterests.length === 0) {
|
||||
console.log('Nothing to backfill.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to ONLY those that came in via the legacy migration — preserves
|
||||
// null on net-new rows where the operator hasn't picked a source yet.
|
||||
const candidateIds = candidateInterests.map((r) => r.id);
|
||||
const legacyLinks = await db
|
||||
.select({ targetEntityId: migrationSourceLinks.targetEntityId })
|
||||
.from(migrationSourceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
|
||||
eq(migrationSourceLinks.targetEntityType, 'interest'),
|
||||
inArray(migrationSourceLinks.targetEntityId, candidateIds),
|
||||
),
|
||||
);
|
||||
|
||||
const legacyIds = new Set(legacyLinks.map((l) => l.targetEntityId));
|
||||
const toUpdate = candidateIds.filter((id) => legacyIds.has(id));
|
||||
|
||||
console.log(
|
||||
`[backfill] of those, ${toUpdate.length} are legacy migration rows (will set source='website')`,
|
||||
);
|
||||
console.log(
|
||||
`[backfill] ${candidateInterests.length - toUpdate.length} are net-new rows (left untouched)`,
|
||||
);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('[backfill] --dry-run set; no writes.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (toUpdate.length === 0) {
|
||||
console.log('Nothing to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update in chunks of 500 to keep query size sane.
|
||||
const CHUNK = 500;
|
||||
let updated = 0;
|
||||
for (let i = 0; i < toUpdate.length; i += CHUNK) {
|
||||
const chunk = toUpdate.slice(i, i + CHUNK);
|
||||
// Belt-and-suspenders: re-assert `source IS NULL` in the WHERE so
|
||||
// a concurrent process that set source on one of these rows
|
||||
// between SELECT and UPDATE doesn't get its value clobbered.
|
||||
const result = await db
|
||||
.update(interests)
|
||||
.set({ source: 'website' })
|
||||
.where(and(inArray(interests.id, chunk), isNull(interests.source)))
|
||||
.returning({ id: interests.id });
|
||||
updated += result.length;
|
||||
}
|
||||
console.log(`[backfill] updated ${updated} rows.`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FATAL', err);
|
||||
process.exit(1);
|
||||
});
|
||||
144
scripts/backfill-phone-e164.ts
Normal file
144
scripts/backfill-phone-e164.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Backfill `client_contacts.value_e164` from `value` for phone / whatsapp
|
||||
* contacts where it's null or empty.
|
||||
*
|
||||
* The legacy seed (and pre-normalization production data) stored phone
|
||||
* numbers in `value` as free text — "+33 4 93 00 0002" — but `value_e164`
|
||||
* is what every UI surface and dedup matcher reads. This script runs the
|
||||
* raw `value` through libphonenumber-js (via the script-safe wrapper to
|
||||
* avoid the Node 25 metadata-loader bug) and writes the canonical E.164
|
||||
* form back.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/backfill-phone-e164.ts # dry-run report
|
||||
* pnpm tsx scripts/backfill-phone-e164.ts --apply # actually write
|
||||
*
|
||||
* The dry-run report prints, for each unparseable row, the contact id +
|
||||
* raw value so you can hand-clean before re-running.
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clientContacts } from '@/lib/db/schema/clients';
|
||||
import { parsePhoneScriptSafe } from '@/lib/dedup/phone-parse';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
interface PhoneRow {
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string | null;
|
||||
valueCountry: string | null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Phone E.164 backfill — ${APPLY ? 'APPLY MODE' : 'dry-run'}`);
|
||||
console.log('');
|
||||
|
||||
// Find candidate rows: phone or whatsapp contacts with a `value` set but
|
||||
// `value_e164` null/empty.
|
||||
const rows: PhoneRow[] = await db
|
||||
.select({
|
||||
id: clientContacts.id,
|
||||
channel: clientContacts.channel,
|
||||
value: clientContacts.value,
|
||||
valueCountry: clientContacts.valueCountry,
|
||||
})
|
||||
.from(clientContacts)
|
||||
.where(
|
||||
and(
|
||||
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
||||
or(isNull(clientContacts.valueE164), eq(clientContacts.valueE164, '')),
|
||||
sql`${clientContacts.value} IS NOT NULL AND ${clientContacts.value} <> ''`,
|
||||
),
|
||||
);
|
||||
|
||||
console.log(` found ${rows.length} candidate rows`);
|
||||
|
||||
let parsedFull = 0;
|
||||
let parsedE164Only = 0;
|
||||
let unparseable = 0;
|
||||
const updates: Array<{
|
||||
id: string;
|
||||
valueE164: string;
|
||||
valueCountry: CountryCode | null;
|
||||
}> = [];
|
||||
const fails: Array<{ id: string; value: string; reason: string }> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.value) continue;
|
||||
const defaultCountry = (row.valueCountry as CountryCode | null) ?? undefined;
|
||||
const parsed1 = parsePhoneScriptSafe(row.value, defaultCountry);
|
||||
|
||||
if (parsed1.e164 && parsed1.country) {
|
||||
// Both e164 + country resolved — best case.
|
||||
updates.push({ id: row.id, valueE164: parsed1.e164, valueCountry: parsed1.country });
|
||||
parsedFull++;
|
||||
} else if (parsed1.e164) {
|
||||
// E.164 came back but country didn't (e.g. UK +44 7700 900xxx
|
||||
// fictional/reserved range — libphonenumber returns the e164 form
|
||||
// but refuses to assign a country). Still safe to write — the e164
|
||||
// is canonical. Country stays null.
|
||||
updates.push({
|
||||
id: row.id,
|
||||
valueE164: parsed1.e164,
|
||||
valueCountry: (row.valueCountry as CountryCode | null) ?? null,
|
||||
});
|
||||
parsedE164Only++;
|
||||
} else {
|
||||
fails.push({
|
||||
id: row.id,
|
||||
value: row.value,
|
||||
reason: row.value.trim().startsWith('+')
|
||||
? 'has + prefix but parse failed'
|
||||
: 'no leading + and no country hint',
|
||||
});
|
||||
unparseable++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(' ✓ parsed cleanly (e164 + country)', parsedFull);
|
||||
console.log(' ✓ parsed e164 only (no country) ', parsedE164Only);
|
||||
console.log(' ✗ unparseable ', unparseable);
|
||||
console.log('');
|
||||
|
||||
if (fails.length > 0) {
|
||||
console.log('Failures (first 10):');
|
||||
for (const f of fails.slice(0, 10)) {
|
||||
console.log(` [${f.id}] "${f.value}" — ${f.reason}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('Dry-run only. Re-run with --apply to write the updates.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
console.log('No updates to write.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Writing ${updates.length} updates...`);
|
||||
|
||||
for (const u of updates) {
|
||||
await db
|
||||
.update(clientContacts)
|
||||
.set({
|
||||
valueE164: u.valueE164,
|
||||
valueCountry: u.valueCountry,
|
||||
})
|
||||
.where(eq(clientContacts.id, u.id));
|
||||
}
|
||||
|
||||
console.log(` ✓ wrote ${updates.length} rows`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
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);
|
||||
});
|
||||
97
scripts/db-reset.ts
Normal file
97
scripts/db-reset.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Wipe all data from the database, preserving schema + drizzle migration
|
||||
* history. Run before swapping seed fixtures.
|
||||
*
|
||||
* pnpm tsx scripts/db-reset.ts (refuses without --confirm)
|
||||
* pnpm tsx scripts/db-reset.ts --confirm
|
||||
*
|
||||
* Truncates every table in the `public` schema except the drizzle
|
||||
* migration tracker, then resets sequences. Wraps the loop in a single
|
||||
* transaction so a mid-wipe failure rolls back cleanly.
|
||||
*
|
||||
* Refuses to run when DATABASE_URL points at anything that doesn't look
|
||||
* like a local/dev host. Override with --i-know-what-im-doing.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const url: string = process.env.DATABASE_URL ?? '';
|
||||
if (!url) {
|
||||
console.error('DATABASE_URL is not set; aborting.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
if (!args.has('--confirm')) {
|
||||
console.error('Refusing to wipe without --confirm');
|
||||
console.error('Run again as: pnpm tsx scripts/db-reset.ts --confirm');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Best-effort safety: refuse for anything that doesn't look like a local DB.
|
||||
function looksLocal(u: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return (
|
||||
parsed.hostname === 'localhost' ||
|
||||
parsed.hostname === '127.0.0.1' ||
|
||||
parsed.hostname === '::1' ||
|
||||
parsed.hostname.endsWith('.local') ||
|
||||
parsed.hostname.endsWith('.internal') ||
|
||||
parsed.hostname === 'host.docker.internal' ||
|
||||
// Docker compose service names commonly used here
|
||||
parsed.hostname === 'postgres' ||
|
||||
parsed.hostname === 'db'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!looksLocal(url) && !args.has('--i-know-what-im-doing')) {
|
||||
console.error(
|
||||
`DATABASE_URL host doesn't look local. Refusing to wipe a remote DB without --i-know-what-im-doing.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
async function main() {
|
||||
console.log('Resetting database...');
|
||||
console.log(` url: ${url.replace(/:[^:@]*@/, ':***@')}`);
|
||||
|
||||
const tables = await sql<{ tablename: string }[]>`
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename NOT LIKE 'drizzle_%'
|
||||
AND tablename != '__drizzle_migrations'
|
||||
`;
|
||||
|
||||
if (tables.length === 0) {
|
||||
console.log(' no user tables found, nothing to do.');
|
||||
await sql.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Single TRUNCATE … CASCADE is faster than per-table loops and handles
|
||||
// FK ordering for us. Quote table names defensively.
|
||||
const tableList = tables.map((t) => `"public"."${t.tablename}"`).join(', ');
|
||||
|
||||
console.log(` truncating ${tables.length} tables...`);
|
||||
await sql.unsafe(`TRUNCATE ${tableList} RESTART IDENTITY CASCADE`);
|
||||
console.log(' done.');
|
||||
|
||||
await sql.end();
|
||||
console.log('');
|
||||
console.log('Database reset complete. Run a seed script next:');
|
||||
console.log(' pnpm db:seed # realistic NocoDB-shaped fixture');
|
||||
console.log(' pnpm db:seed:synthetic # one client per pipeline stage');
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Reset failed:', err);
|
||||
await sql.end().catch(() => undefined);
|
||||
process.exit(1);
|
||||
});
|
||||
83
scripts/dev-open-browser.ts
Normal file
83
scripts/dev-open-browser.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Launch a headed Chromium with NO viewport override so it adopts the
|
||||
* host monitor's natural size — useful when you want to drive the CRM
|
||||
* manually and have full-screen real estate.
|
||||
*
|
||||
* Pre-fills the login form for the synthetic admin (admin@portnimara.test
|
||||
* / SuperAdmin12345!) but does not submit; press Enter when ready.
|
||||
*
|
||||
* The script keeps running until the browser window is closed by the
|
||||
* user or until you Ctrl-C.
|
||||
*
|
||||
* pnpm tsx scripts/dev-open-browser.ts # super_admin
|
||||
* pnpm tsx scripts/dev-open-browser.ts sales_agent
|
||||
* pnpm tsx scripts/dev-open-browser.ts viewer
|
||||
* pnpm tsx scripts/dev-open-browser.ts --no-prefill
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
// @playwright/test re-exports the same chromium driver and is already
|
||||
// installed as a dev dep; using it avoids needing to add the standalone
|
||||
// `playwright` package as a separate dependency.
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const USERS: Record<string, { email: string; password: string }> = {
|
||||
super_admin: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!' },
|
||||
sales_agent: { email: 'agent@portnimara.test', password: 'SalesAgent12345!' },
|
||||
viewer: { email: 'viewer@portnimara.test', password: 'ViewerUser12345!' },
|
||||
};
|
||||
|
||||
const BASE_URL = process.env.DEV_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const noPrefill = args.includes('--no-prefill');
|
||||
const role =
|
||||
args.find((a) => !a.startsWith('--')) && USERS[args.find((a) => !a.startsWith('--'))!]
|
||||
? args.find((a) => !a.startsWith('--'))!
|
||||
: 'super_admin';
|
||||
const user = USERS[role]!;
|
||||
|
||||
console.log(`Launching headed Chromium → ${BASE_URL}`);
|
||||
console.log(` role: ${role} (${user.email})`);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
args: ['--start-maximized'],
|
||||
});
|
||||
|
||||
// viewport: null lets the page fill the OS window. Combined with
|
||||
// --start-maximized this matches the host monitor's natural size.
|
||||
const context = await browser.newContext({ viewport: null });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
|
||||
if (!noPrefill) {
|
||||
try {
|
||||
await page.waitForSelector('#email', { timeout: 5000 });
|
||||
await page.fill('#email', user.email);
|
||||
await page.fill('#password', user.password);
|
||||
console.log(' Login form pre-filled — press Enter in the browser to submit.');
|
||||
} catch {
|
||||
console.log(' Could not find login form (page may have redirected).');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log("Browser is open. Close it when you're done; the script will exit.");
|
||||
console.log('Or Ctrl-C here to force-quit.');
|
||||
|
||||
// Keep the process alive until the browser window is closed.
|
||||
await new Promise<void>((resolve) => {
|
||||
browser.on('disconnected', () => resolve());
|
||||
});
|
||||
|
||||
await browser.close().catch(() => undefined);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Open-browser failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
52
scripts/dev-recommender-smoke.ts
Normal file
52
scripts/dev-recommender-smoke.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Dev-only smoke check for the berth recommender. Resolves the first
|
||||
* port-nimara interest (with desired dims set) and prints the top-N
|
||||
* recommendations.
|
||||
*
|
||||
* pnpm tsx scripts/dev-recommender-smoke.ts
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { eq, isNotNull, and } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { recommendBerths } from '@/lib/services/berth-recommender.service';
|
||||
|
||||
async function main() {
|
||||
const [port] = await db
|
||||
.select({ id: ports.id })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, 'port-nimara'))
|
||||
.limit(1);
|
||||
if (!port) throw new Error('port-nimara not found');
|
||||
|
||||
const [interest] = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, port.id), isNotNull(interests.desiredLengthFt)))
|
||||
.limit(1);
|
||||
if (!interest) throw new Error('No interest with desired dims set');
|
||||
|
||||
console.log(`> Recommending berths for interest ${interest.id} on port ${port.id}…`);
|
||||
const recs = await recommendBerths({
|
||||
interestId: interest.id,
|
||||
portId: port.id,
|
||||
});
|
||||
|
||||
console.log(`> ${recs.length} recommendations:`);
|
||||
for (const r of recs) {
|
||||
console.log(
|
||||
` ${r.mooringNumber.padEnd(5)} tier=${r.tier} fit=${r.fitScore} ` +
|
||||
`${r.lengthFt}×${r.widthFt}×${r.draftFt} ft buf=${r.sizeBufferPct}% ` +
|
||||
`${r.reasons.dimensional}; ${r.reasons.pipeline}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
40
scripts/dev-set-password.ts
Normal file
40
scripts/dev-set-password.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Dev helper: set a user's password directly (bypasses email reset).
|
||||
* Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { hashPassword } from 'better-auth/crypto';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { user, account } from '@/lib/db/schema/users';
|
||||
|
||||
async function main() {
|
||||
const [, , email, password] = process.argv;
|
||||
if (!email || !password) {
|
||||
console.error('Usage: pnpm tsx scripts/dev-set-password.ts <email> <password>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const u = await db.query.user.findFirst({ where: eq(user.email, email) });
|
||||
if (!u) {
|
||||
console.error(`User not found: ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hash = await hashPassword(password);
|
||||
const result = await db
|
||||
.update(account)
|
||||
.set({ password: hash, updatedAt: new Date() })
|
||||
.where(and(eq(account.userId, u.id), eq(account.providerId, 'credential')))
|
||||
.returning({ id: account.id });
|
||||
|
||||
if (result.length === 0) {
|
||||
console.error(`No credential account row for ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Updated password for ${email} (account id ${result[0]?.id}).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
409
scripts/import-berths-from-nocodb.ts
Normal file
409
scripts/import-berths-from-nocodb.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Idempotent NocoDB Berths → CRM `berths` import.
|
||||
*
|
||||
* Re-running picks up NocoDB additions/edits without clobbering CRM-side
|
||||
* overrides: rows where `updated_at > last_imported_at` are treated as
|
||||
* human-edited and skipped (use `--force` to override). Map Data JSON
|
||||
* is validated and upserted into `berth_map_data` as a separate step.
|
||||
*
|
||||
* Usage:
|
||||
* 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]
|
||||
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply --force
|
||||
* pnpm tsx scripts/import-berths-from-nocodb.ts --apply --update-snapshot
|
||||
*
|
||||
* Edge cases mitigated (see plan §14.1):
|
||||
* - Mooring collisions : unique (port_id, mooring_number) on the table.
|
||||
* - Concurrent runs : pg_advisory_xact_lock on a stable key.
|
||||
* - Numeric-with-units : parseDecimalWithUnit() strips trailing units.
|
||||
* - Metric drift : NocoDB metric formula columns are ignored;
|
||||
* metric values are recomputed from imperial.
|
||||
* - Map Data shape : zod-validated; failures are skipped silently
|
||||
* rather than aborting the whole import.
|
||||
* - Status enum : NocoDB display strings → CRM snake_case.
|
||||
* - NocoDB row deleted : reported as "orphaned in CRM"; not auto-deleted.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
||||
import { fetchAllRows, loadNocoDbConfig, NOCO_TABLES } from '@/lib/dedup/nocodb-source';
|
||||
import {
|
||||
buildPlan,
|
||||
mapRow,
|
||||
type Action,
|
||||
type ImportedBerth,
|
||||
type PlanEntry,
|
||||
type ExistingBerthRow,
|
||||
} from '@/lib/services/berth-import';
|
||||
|
||||
// ─── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CliArgs {
|
||||
dryRun: boolean;
|
||||
apply: boolean;
|
||||
portSlug: string;
|
||||
force: boolean;
|
||||
updateSnapshot: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
dryRun: false,
|
||||
apply: false,
|
||||
portSlug: 'port-nimara',
|
||||
force: false,
|
||||
updateSnapshot: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '--port-slug') args.portSlug = argv[++i] ?? 'port-nimara';
|
||||
else if (a === '--force') args.force = true;
|
||||
else if (a === '--update-snapshot') args.updateSnapshot = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Unknown argument: ${a}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (!args.dryRun && !args.apply) {
|
||||
console.error('Must specify either --dry-run or --apply.');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage:
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --dry-run [--port-slug <slug>]
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --apply [--port-slug <slug>] [--force] [--update-snapshot]
|
||||
|
||||
Flags:
|
||||
--dry-run Read NocoDB + diff vs CRM. No writes.
|
||||
--apply Apply the plan to the DB.
|
||||
--port-slug <slug> Target port slug (default: port-nimara).
|
||||
--force Overwrite rows where CRM updated_at > last_imported_at.
|
||||
--update-snapshot Rewrite src/lib/db/seed-data/berths.json after apply.
|
||||
-h, --help Show this help.
|
||||
`);
|
||||
}
|
||||
|
||||
// ─── Stable advisory lock key ───────────────────────────────────────────────
|
||||
// 64-bit BIGINT - first 4 bytes spell "BRTH" so it's grep-able in pg_locks.
|
||||
const BERTH_IMPORT_LOCK_KEY = 0x4252544800000001n;
|
||||
|
||||
// ─── Apply ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ApplyResult {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
mapDataWritten: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
async function apply(
|
||||
portId: string,
|
||||
plan: PlanEntry[],
|
||||
orphans: ExistingBerthRow[],
|
||||
importedAt: Date,
|
||||
): Promise<ApplyResult> {
|
||||
const result: ApplyResult = {
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
mapDataWritten: 0,
|
||||
warnings: [],
|
||||
};
|
||||
for (const orphan of orphans) {
|
||||
result.warnings.push(
|
||||
`Orphan: CRM has mooring="${orphan.mooringNumber}" but NocoDB no longer does (id=${orphan.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Stable lock so two simultaneous --apply runs serialize.
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(${BERTH_IMPORT_LOCK_KEY})`);
|
||||
|
||||
for (const entry of plan) {
|
||||
if (entry.action === 'skip-edited' || entry.action === 'noop') {
|
||||
result.skipped += 1;
|
||||
result.warnings.push(`Skipped ${entry.imported.mooringNumber}: ${entry.reason ?? 'no-op'}`);
|
||||
continue;
|
||||
}
|
||||
const i = entry.imported;
|
||||
const n = i.numerics;
|
||||
const baseValues = {
|
||||
portId,
|
||||
mooringNumber: i.mooringNumber,
|
||||
area: i.area,
|
||||
status: i.status,
|
||||
lengthFt: n.lengthFt != null ? String(n.lengthFt) : null,
|
||||
widthFt: n.widthFt != null ? String(n.widthFt) : null,
|
||||
draftFt: n.draftFt != null ? String(n.draftFt) : null,
|
||||
lengthM: n.lengthM != null ? String(n.lengthM) : null,
|
||||
widthM: n.widthM != null ? String(n.widthM) : null,
|
||||
draftM: n.draftM != null ? String(n.draftM) : null,
|
||||
widthIsMinimum: i.widthIsMinimum,
|
||||
nominalBoatSize: n.nominalBoatSize != null ? String(n.nominalBoatSize) : null,
|
||||
nominalBoatSizeM: n.nominalBoatSizeM != null ? String(n.nominalBoatSizeM) : null,
|
||||
waterDepth: n.waterDepth != null ? String(n.waterDepth) : null,
|
||||
waterDepthM: n.waterDepthM != null ? String(n.waterDepthM) : null,
|
||||
waterDepthIsMinimum: i.waterDepthIsMinimum,
|
||||
sidePontoon: i.sidePontoon,
|
||||
powerCapacity: n.powerCapacity != null ? String(n.powerCapacity) : null,
|
||||
voltage: n.voltage != null ? String(n.voltage) : null,
|
||||
mooringType: i.mooringType,
|
||||
cleatType: i.cleatType,
|
||||
cleatCapacity: i.cleatCapacity,
|
||||
bollardType: i.bollardType,
|
||||
bollardCapacity: i.bollardCapacity,
|
||||
access: i.access,
|
||||
price: n.price != null ? String(n.price) : null,
|
||||
priceCurrency: 'USD' as const,
|
||||
bowFacing: i.bowFacing,
|
||||
berthApproved: i.berthApproved,
|
||||
statusOverrideMode: i.statusOverrideMode,
|
||||
lastImportedAt: importedAt,
|
||||
updatedAt: importedAt,
|
||||
};
|
||||
|
||||
let berthId: string;
|
||||
if (entry.action === 'insert') {
|
||||
const [inserted] = await tx
|
||||
.insert(berths)
|
||||
.values({ ...baseValues, tenureType: 'permanent' })
|
||||
.returning({ id: berths.id });
|
||||
berthId = inserted!.id;
|
||||
result.inserted += 1;
|
||||
} else {
|
||||
await tx.update(berths).set(baseValues).where(eq(berths.id, entry.existing!.id));
|
||||
berthId = entry.existing!.id;
|
||||
result.updated += 1;
|
||||
}
|
||||
|
||||
if (i.mapData) {
|
||||
const mapValues = {
|
||||
berthId,
|
||||
svgPath: i.mapData.path ?? null,
|
||||
x: i.mapData.x != null ? String(i.mapData.x) : null,
|
||||
y: i.mapData.y != null ? String(i.mapData.y) : null,
|
||||
transform: i.mapData.transform ?? null,
|
||||
fontSize: i.mapData.fontSize != null ? String(i.mapData.fontSize) : null,
|
||||
updatedAt: importedAt,
|
||||
};
|
||||
await tx
|
||||
.insert(berthMapData)
|
||||
.values(mapValues)
|
||||
.onConflictDoUpdate({
|
||||
target: berthMapData.berthId,
|
||||
set: {
|
||||
svgPath: mapValues.svgPath,
|
||||
x: mapValues.x,
|
||||
y: mapValues.y,
|
||||
transform: mapValues.transform,
|
||||
fontSize: mapValues.fontSize,
|
||||
updatedAt: importedAt,
|
||||
},
|
||||
});
|
||||
result.mapDataWritten += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Snapshot writer (for seed-data refresh) ────────────────────────────────
|
||||
|
||||
async function writeSnapshot(imported: ImportedBerth[]): Promise<string> {
|
||||
// Ordering: idx 0..4 available (small), 5..9 under_offer (medium),
|
||||
// 10..11 sold (large), then everything else by mooring number. The
|
||||
// first 12 indexes feed `seed-data.ts` interest/reservation stubs.
|
||||
const sortByLength = (a: ImportedBerth, b: ImportedBerth) =>
|
||||
(a.numerics.lengthFt ?? 0) - (b.numerics.lengthFt ?? 0);
|
||||
const available = imported
|
||||
.filter((b) => b.status === 'available')
|
||||
.sort(sortByLength)
|
||||
.slice(0, 5);
|
||||
const underOffer = imported
|
||||
.filter((b) => b.status === 'under_offer')
|
||||
.sort(sortByLength)
|
||||
.slice(0, 5);
|
||||
const sold = imported
|
||||
.filter((b) => b.status === 'sold')
|
||||
.sort((a, b) => -sortByLength(a, b))
|
||||
.slice(0, 2);
|
||||
const featured = new Set([...available, ...underOffer, ...sold].map((b) => b.mooringNumber));
|
||||
const rest = imported
|
||||
.filter((b) => !featured.has(b.mooringNumber))
|
||||
.sort((a, b) => a.mooringNumber.localeCompare(b.mooringNumber, 'en', { numeric: true }));
|
||||
const ordered = [...available, ...underOffer, ...sold, ...rest];
|
||||
|
||||
const payload = ordered.map((b) => ({
|
||||
legacyId: b.legacyId,
|
||||
mooringNumber: b.mooringNumber,
|
||||
area: b.area,
|
||||
status: b.status,
|
||||
lengthFt: b.numerics.lengthFt,
|
||||
widthFt: b.numerics.widthFt,
|
||||
draftFt: b.numerics.draftFt,
|
||||
lengthM: b.numerics.lengthM,
|
||||
widthM: b.numerics.widthM,
|
||||
draftM: b.numerics.draftM,
|
||||
widthIsMinimum: b.widthIsMinimum,
|
||||
nominalBoatSize: b.numerics.nominalBoatSize,
|
||||
nominalBoatSizeM: b.numerics.nominalBoatSizeM,
|
||||
waterDepth: b.numerics.waterDepth,
|
||||
waterDepthM: b.numerics.waterDepthM,
|
||||
waterDepthIsMinimum: b.waterDepthIsMinimum,
|
||||
sidePontoon: b.sidePontoon,
|
||||
powerCapacity: b.numerics.powerCapacity,
|
||||
voltage: b.numerics.voltage,
|
||||
mooringType: b.mooringType,
|
||||
cleatType: b.cleatType,
|
||||
cleatCapacity: b.cleatCapacity,
|
||||
bollardType: b.bollardType,
|
||||
bollardCapacity: b.bollardCapacity,
|
||||
access: b.access,
|
||||
price: b.numerics.price,
|
||||
bowFacing: b.bowFacing,
|
||||
berthApproved: b.berthApproved,
|
||||
statusOverrideMode: b.statusOverrideMode,
|
||||
}));
|
||||
|
||||
const target = path.resolve(process.cwd(), 'src/lib/db/seed-data/berths.json');
|
||||
await fs.writeFile(target, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
||||
return target;
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const config = loadNocoDbConfig();
|
||||
|
||||
const [port] = await db
|
||||
.select({ id: ports.id, slug: ports.slug })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, args.portSlug))
|
||||
.limit(1);
|
||||
if (!port) {
|
||||
console.error(`No port found with slug "${args.portSlug}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`> Fetching NocoDB Berths…`);
|
||||
const rows = await fetchAllRows(NOCO_TABLES.berths, config);
|
||||
console.log(` fetched ${rows.length} rows from NocoDB`);
|
||||
|
||||
const imported: ImportedBerth[] = [];
|
||||
let skippedMalformed = 0;
|
||||
for (const r of rows) {
|
||||
const m = mapRow(r);
|
||||
if (m) imported.push(m);
|
||||
else skippedMalformed += 1;
|
||||
}
|
||||
if (skippedMalformed > 0) {
|
||||
console.warn(` ${skippedMalformed} rows skipped (missing Mooring Number)`);
|
||||
}
|
||||
|
||||
// De-dup against any same-mooring twins surfacing from NocoDB
|
||||
// (defensive — the Berths table is keyed on Mooring Number in NocoDB).
|
||||
const seen = new Set<string>();
|
||||
const dedup: ImportedBerth[] = [];
|
||||
for (const b of imported) {
|
||||
if (seen.has(b.mooringNumber)) {
|
||||
console.warn(` duplicate mooring "${b.mooringNumber}" in NocoDB — keeping first`);
|
||||
continue;
|
||||
}
|
||||
seen.add(b.mooringNumber);
|
||||
dedup.push(b);
|
||||
}
|
||||
|
||||
console.log(`> Reading current CRM berths for port "${port.slug}"…`);
|
||||
const existingRows = await db
|
||||
.select({
|
||||
id: berths.id,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
updatedAt: berths.updatedAt,
|
||||
lastImportedAt: berths.lastImportedAt,
|
||||
})
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, port.id));
|
||||
console.log(` ${existingRows.length} existing rows`);
|
||||
|
||||
const existingByMooring = new Map(existingRows.map((r) => [r.mooringNumber, r]));
|
||||
const { plan, orphans } = buildPlan(dedup, existingByMooring, args.force);
|
||||
|
||||
const counts = plan.reduce(
|
||||
(acc, e) => {
|
||||
acc[e.action] += 1;
|
||||
return acc;
|
||||
},
|
||||
{ insert: 0, update: 0, 'skip-edited': 0, noop: 0 } as Record<Action, number>,
|
||||
);
|
||||
|
||||
console.log(`> Plan:`);
|
||||
console.log(` insert : ${counts.insert}`);
|
||||
console.log(` update : ${counts.update}`);
|
||||
console.log(` skip-edited : ${counts['skip-edited']}`);
|
||||
console.log(` no-op : ${counts.noop}`);
|
||||
console.log(` orphans (CRM): ${orphans.length}`);
|
||||
|
||||
if (counts['skip-edited'] > 0) {
|
||||
console.log(` ↳ Skipped (CRM-edited; pass --force to overwrite):`);
|
||||
for (const e of plan.filter((p) => p.action === 'skip-edited').slice(0, 10)) {
|
||||
console.log(` - ${e.imported.mooringNumber} ${e.reason}`);
|
||||
}
|
||||
if (counts['skip-edited'] > 10) console.log(` …and ${counts['skip-edited'] - 10} more`);
|
||||
}
|
||||
if (orphans.length > 0) {
|
||||
console.log(` ↳ Orphans (in CRM but missing from NocoDB):`);
|
||||
for (const o of orphans.slice(0, 10)) console.log(` - ${o.mooringNumber}`);
|
||||
if (orphans.length > 10) console.log(` …and ${orphans.length - 10} more`);
|
||||
}
|
||||
|
||||
// Snapshot write is independent of DB writes — even in --dry-run mode
|
||||
// a rep may want to refresh the seed JSON to capture the latest NocoDB
|
||||
// shape without committing to the DB import. The original gate dropped
|
||||
// this silently when --dry-run was passed; audit caught it.
|
||||
if (args.updateSnapshot) {
|
||||
const written = await writeSnapshot(dedup);
|
||||
console.log(`> Wrote ${dedup.length} rows to ${path.relative(process.cwd(), written)}`);
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(`\n[dry-run] no DB writes performed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`> Applying…`);
|
||||
const result = await apply(port.id, plan, orphans, new Date());
|
||||
console.log(` inserted : ${result.inserted}`);
|
||||
console.log(` updated : ${result.updated}`);
|
||||
console.log(` skipped : ${result.skipped}`);
|
||||
console.log(` map data writes : ${result.mapDataWritten}`);
|
||||
if (result.warnings.length) {
|
||||
console.log(` warnings :`);
|
||||
for (const w of result.warnings.slice(0, 20)) console.log(` - ${w}`);
|
||||
if (result.warnings.length > 20) console.log(` …and ${result.warnings.length - 20} more`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err: unknown) => {
|
||||
console.error(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);
|
||||
});
|
||||
251
scripts/migrate-from-nocodb.ts
Normal file
251
scripts/migrate-from-nocodb.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* One-shot migration: legacy NocoDB Interests → new client/interest split.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run
|
||||
* Pulls the live NocoDB base, runs the transform + dedup pipeline,
|
||||
* writes a report to .migration/<timestamp>/. NO database writes.
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug port-nimara
|
||||
* Same, but tags the planned writes with the named port (matters for
|
||||
* the apply phase — every client/interest belongs to one port).
|
||||
*
|
||||
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug port-nimara
|
||||
* Re-fetches NocoDB, re-transforms, then writes the planned rows
|
||||
* into the target port via the idempotent `migration_source_links`
|
||||
* ledger. Re-runs are safe — already-imported source IDs are skipped.
|
||||
* REQUIRES `EMAIL_REDIRECT_TO` to be set in env (safety net) unless
|
||||
* `--unsafe-skip-redirect-check` is also passed.
|
||||
*
|
||||
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { applyPlan } from '@/lib/dedup/migration-apply';
|
||||
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
|
||||
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
||||
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
|
||||
|
||||
interface CliArgs {
|
||||
dryRun: boolean;
|
||||
apply: boolean;
|
||||
portSlug: string | null;
|
||||
reportDir: string | null;
|
||||
unsafeSkipRedirectCheck: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
dryRun: false,
|
||||
apply: false,
|
||||
portSlug: null,
|
||||
reportDir: null,
|
||||
unsafeSkipRedirectCheck: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i]!;
|
||||
if (a === '--dry-run') args.dryRun = true;
|
||||
else if (a === '--apply') args.apply = true;
|
||||
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||
else if (a === '--report') args.reportDir = argv[++i] ?? null;
|
||||
else if (a === '--unsafe-skip-redirect-check') args.unsafeSkipRedirectCheck = true;
|
||||
else if (a === '-h' || a === '--help') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Unknown argument: ${a}`);
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Usage:
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug <slug>]
|
||||
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
|
||||
No database writes.
|
||||
|
||||
pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug <slug>
|
||||
Re-fetches NocoDB, re-transforms, writes via migration_source_links
|
||||
ledger. Idempotent — safe to re-run. Requires EMAIL_REDIRECT_TO set
|
||||
(unless --unsafe-skip-redirect-check is also passed).
|
||||
|
||||
Flags:
|
||||
--dry-run Read NocoDB, write report only.
|
||||
--apply Actually write rows to the DB.
|
||||
--port-slug <slug> Port slug to attach to all imported
|
||||
entities. Defaults to the first
|
||||
available port if omitted.
|
||||
--report <dir> Path to a previously-generated report
|
||||
dir (only used by --apply).
|
||||
--unsafe-skip-redirect-check Skip the EMAIL_REDIRECT_TO precondition
|
||||
check. Only use in production cutover.
|
||||
-h, --help Show this help.
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target port: use the slug if provided, otherwise the first
|
||||
* port found. Errors out cleanly if the slug doesn't match any port.
|
||||
*/
|
||||
async function resolvePort(slug: string | null): Promise<{ id: string; slug: string }> {
|
||||
if (slug) {
|
||||
const [p] = await db
|
||||
.select({ id: ports.id, slug: ports.slug })
|
||||
.from(ports)
|
||||
.where(eq(ports.slug, slug))
|
||||
.limit(1);
|
||||
if (!p) {
|
||||
console.error(`No port found with slug "${slug}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
return { id: p.id, slug: p.slug };
|
||||
}
|
||||
const [first] = await db.select({ id: ports.id, slug: ports.slug }).from(ports).limit(1);
|
||||
if (!first) {
|
||||
console.error('No ports exist in the target DB. Seed at least one port before applying.');
|
||||
process.exit(1);
|
||||
}
|
||||
return { id: first.id, slug: first.slug };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.dryRun && !args.apply) {
|
||||
console.error('Must specify --dry-run or --apply');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Safety gate: --apply must run with EMAIL_REDIRECT_TO set, unless the
|
||||
// operator explicitly opts out (production cutover).
|
||||
if (args.apply && !process.env.EMAIL_REDIRECT_TO && !args.unsafeSkipRedirectCheck) {
|
||||
console.error(
|
||||
'--apply requires EMAIL_REDIRECT_TO to be set in the environment as a safety net.',
|
||||
);
|
||||
console.error('See docs/operations/outbound-comms-safety.md for the rationale.');
|
||||
console.error(
|
||||
'If you are running the production cutover and have read that doc, add ' +
|
||||
'--unsafe-skip-redirect-check to override.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ── Fetch + transform (shared by dry-run and apply) ──────────────────────
|
||||
|
||||
console.log('[migrate] Loading NocoDB config…');
|
||||
const config = loadNocoDbConfig();
|
||||
console.log(`[migrate] Source: ${config.url}`);
|
||||
|
||||
console.log('[migrate] Fetching snapshot from NocoDB…');
|
||||
const start = Date.now();
|
||||
const snapshot = await fetchSnapshot(config);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(
|
||||
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths.`,
|
||||
);
|
||||
|
||||
console.log('[migrate] Running transform + dedup pipeline…');
|
||||
const plan = transformSnapshot(snapshot);
|
||||
|
||||
// Resolve output paths relative to the worktree root.
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const generatedAt = new Date().toISOString();
|
||||
const paths = resolveReportPaths(repoRoot);
|
||||
|
||||
console.log(`[migrate] Writing report to ${paths.rootDir}…`);
|
||||
await writeReport(paths, plan, generatedAt);
|
||||
|
||||
// ── Plan summary ─────────────────────────────────────────────────────────
|
||||
const s = plan.stats;
|
||||
console.log('');
|
||||
console.log('=== Migration Plan Summary ===');
|
||||
console.log(
|
||||
` Input: ${s.inputInterestRows} interests, ${s.inputResidentialRows} residential interests`,
|
||||
);
|
||||
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
|
||||
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
|
||||
console.log(
|
||||
` ${s.outputDocuments} EOI documents, ${s.outputDocumentSigners} signers`,
|
||||
);
|
||||
console.log(
|
||||
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
|
||||
);
|
||||
console.log(
|
||||
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
||||
);
|
||||
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
|
||||
console.log('');
|
||||
console.log(` Full report: ${paths.summaryPath}`);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('');
|
||||
console.log('Dry-run complete. Re-run with --apply to write rows.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Apply path ───────────────────────────────────────────────────────────
|
||||
|
||||
const port = await resolvePort(args.portSlug);
|
||||
const applyId = randomUUID();
|
||||
|
||||
console.log('');
|
||||
console.log(`[migrate] Applying to port "${port.slug}" (id=${port.id})`);
|
||||
console.log(`[migrate] Apply id: ${applyId}`);
|
||||
console.log('[migrate] Inserting…');
|
||||
|
||||
const applyStart = Date.now();
|
||||
const result = await applyPlan(plan, { port, applyId });
|
||||
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
|
||||
|
||||
console.log('');
|
||||
console.log('=== Apply Result ===');
|
||||
console.log(` Time: ${applyElapsed}s`);
|
||||
console.log(
|
||||
` Clients: ${result.clientsInserted} inserted, ${result.clientsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Contacts: ${result.contactsInserted} inserted`);
|
||||
console.log(` Addresses: ${result.addressesInserted} inserted`);
|
||||
console.log(` Yachts: ${result.yachtsInserted} inserted`);
|
||||
console.log(
|
||||
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
|
||||
);
|
||||
console.log(
|
||||
` Documents: ${result.documentsInserted} inserted, ${result.documentsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Signers: ${result.documentSignersInserted} inserted`);
|
||||
console.log(
|
||||
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
|
||||
);
|
||||
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('');
|
||||
console.log('Warnings:');
|
||||
for (const w of result.warnings.slice(0, 20)) {
|
||||
console.log(` - ${w}`);
|
||||
}
|
||||
if (result.warnings.length > 20) {
|
||||
console.log(` … ${result.warnings.length - 20} more`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[migrate] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
29
scripts/migrate-storage.ts
Normal file
29
scripts/migrate-storage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Storage backend migration CLI — see §4.7a + §14.9a of
|
||||
* docs/berth-recommender-and-pdf-plan.md.
|
||||
*
|
||||
* pnpm tsx scripts/migrate-storage.ts --from s3 --to filesystem [--dry-run]
|
||||
* pnpm tsx scripts/migrate-storage.ts --from filesystem --to s3
|
||||
*
|
||||
* The actual migration logic lives in `src/lib/storage/migrate.ts` so the
|
||||
* admin UI's "Switch backend" button can run the exact same code path. This
|
||||
* file is a thin CLI wrapper.
|
||||
*/
|
||||
|
||||
import { logger } from '@/lib/logger';
|
||||
import { parseArgs, runMigration } from '@/lib/storage/migrate';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
logger.info({ args }, 'Starting storage migration');
|
||||
const result = await runMigration(args);
|
||||
logger.info({ result }, 'Storage migration complete');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error({ err }, 'Storage migration failed');
|
||||
console.error(err);
|
||||
process.exit(2);
|
||||
});
|
||||
106
scripts/smoke-test-redirect.ts
Normal file
106
scripts/smoke-test-redirect.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Live smoke test for EMAIL_REDIRECT_TO.
|
||||
*
|
||||
* Actually calls `sendEmail()` (the centralized helper used by every
|
||||
* outbound email path in the app) with a fake real-client address. The
|
||||
* SMTP transporter is monkey-patched to capture the message instead of
|
||||
* actually delivering it, so this is safe to run anywhere.
|
||||
*
|
||||
* Prints the captured `to` + `subject` so the operator can see with their
|
||||
* own eyes that the redirect happened. Exits non-zero if the redirect
|
||||
* failed for any reason.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx scripts/smoke-test-redirect.ts
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
|
||||
async function main() {
|
||||
const expectedRedirect = process.env.EMAIL_REDIRECT_TO;
|
||||
if (!expectedRedirect) {
|
||||
console.error('FAIL: EMAIL_REDIRECT_TO is not set in env. Set it before running this test.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[smoke] EMAIL_REDIRECT_TO = ${expectedRedirect}`);
|
||||
console.log('');
|
||||
|
||||
// Monkey-patch nodemailer's createTransport so we capture the call
|
||||
// without actually delivering. This is the same pattern the unit
|
||||
// tests use, but at the live import-time level so we're testing the
|
||||
// exact code path that runs in production.
|
||||
const nodemailer = await import('nodemailer');
|
||||
const captured: Array<{ to: unknown; subject: unknown; from: unknown }> = [];
|
||||
const originalCreateTransport = nodemailer.default.createTransport;
|
||||
nodemailer.default.createTransport = (() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sendMail: async (msg: any) => {
|
||||
captured.push({ to: msg.to, subject: msg.subject, from: msg.from });
|
||||
return { messageId: '<smoke@test>', accepted: [msg.to], rejected: [] };
|
||||
},
|
||||
})) as unknown as typeof nodemailer.default.createTransport;
|
||||
|
||||
// Now import sendEmail (gets the patched transporter).
|
||||
const { sendEmail } = await import('@/lib/email');
|
||||
|
||||
const realClientEmail = 'real-client-DO-NOT-EMAIL@example.test';
|
||||
const realSubject = 'Important: Your contract is ready';
|
||||
|
||||
console.log('[smoke] calling sendEmail(...) with:');
|
||||
console.log(` to: ${realClientEmail}`);
|
||||
console.log(` subject: "${realSubject}"`);
|
||||
console.log('');
|
||||
|
||||
await sendEmail(realClientEmail, realSubject, '<p>Body unused for this smoke.</p>');
|
||||
|
||||
// Restore the original transport (be a good citizen).
|
||||
nodemailer.default.createTransport = originalCreateTransport;
|
||||
|
||||
console.log('[smoke] captured outbound message:');
|
||||
console.log(` to: ${captured[0]?.to}`);
|
||||
console.log(` subject: "${captured[0]?.subject}"`);
|
||||
console.log(` from: ${captured[0]?.from}`);
|
||||
console.log('');
|
||||
|
||||
// Assertions
|
||||
let pass = true;
|
||||
|
||||
if (captured.length !== 1) {
|
||||
console.error(`FAIL: expected exactly 1 sendMail call, got ${captured.length}`);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (captured[0]?.to !== expectedRedirect) {
|
||||
console.error(
|
||||
`FAIL: outbound "to" was "${captured[0]?.to}", expected the redirect address "${expectedRedirect}"`,
|
||||
);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof captured[0]?.subject !== 'string' ||
|
||||
!captured[0].subject.startsWith(`[redirected from ${realClientEmail}]`)
|
||||
) {
|
||||
console.error(
|
||||
`FAIL: subject did not get the [redirected from <orig>] prefix. Got: "${captured[0]?.subject}"`,
|
||||
);
|
||||
pass = false;
|
||||
}
|
||||
|
||||
if (pass) {
|
||||
console.log('PASS: EMAIL_REDIRECT_TO is intercepting outbound email correctly.');
|
||||
console.log(
|
||||
' The "to" header matches the redirect, and the original recipient is preserved in the subject.',
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('');
|
||||
console.error('Smoke test FAILED. Do not import production data until this is fixed.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FATAL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
42
scripts/test-currency-api.ts
Normal file
42
scripts/test-currency-api.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Quick verification: live Frankfurter API → DB upsert → getRate read.
|
||||
* Run with `pnpm tsx scripts/test-currency-api.ts`.
|
||||
*/
|
||||
import { refreshRates, getRate, convert } from '@/lib/services/currency';
|
||||
|
||||
async function main() {
|
||||
console.log('1. Fetching live rates from Frankfurter…');
|
||||
await refreshRates();
|
||||
|
||||
console.log('2. Reading round-trip rates from DB:');
|
||||
const usdEur = await getRate('USD', 'EUR');
|
||||
const eurUsd = await getRate('EUR', 'USD');
|
||||
const usdGbp = await getRate('USD', 'GBP');
|
||||
const eurGbp = await getRate('EUR', 'GBP');
|
||||
const usdUsd = await getRate('USD', 'USD');
|
||||
|
||||
console.log(` USD→EUR: ${usdEur}`);
|
||||
console.log(` EUR→USD: ${eurUsd}`);
|
||||
console.log(` USD→GBP: ${usdGbp}`);
|
||||
console.log(` EUR→GBP: ${eurGbp ?? '(no direct row, expected)'}`);
|
||||
console.log(` USD→USD: ${usdUsd}`);
|
||||
|
||||
console.log('3. Convert sample amounts:');
|
||||
const c1 = await convert(1000, 'USD', 'EUR');
|
||||
console.log(` $1000 → ${c1?.result} EUR @ ${c1?.rate}`);
|
||||
const c2 = await convert(500, 'EUR', 'USD');
|
||||
console.log(` €500 → $${c2?.result} @ ${c2?.rate}`);
|
||||
|
||||
// Sanity: EUR→USD should be ≈ 1 / (USD→EUR), within rounding
|
||||
if (usdEur && eurUsd) {
|
||||
const drift = Math.abs(eurUsd - 1 / usdEur);
|
||||
console.log(`4. Inverse-rate drift: ${drift.toFixed(6)} (≤0.001 = healthy)`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Currency test failed:', 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>
|
||||
);
|
||||
}
|
||||
144
src/app/(dashboard)/[portSlug]/admin/ai/page.tsx
Normal file
144
src/app/(dashboard)/[portSlug]/admin/ai/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import Link from 'next/link';
|
||||
import { Bot, FileText, Brain, ExternalLink } from 'lucide-react';
|
||||
|
||||
import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} 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[] = [
|
||||
{
|
||||
key: 'ai_enabled',
|
||||
label: 'AI features enabled',
|
||||
description:
|
||||
'Master switch. When OFF, every AI surface (receipt OCR fallback, berth-PDF AI parse, future embedding-driven recommendations) is bypassed. Provider keys stay configured but unused.',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'ai_monthly_token_cap',
|
||||
label: 'Monthly token cap (this port)',
|
||||
description:
|
||||
'Soft cap on total AI tokens consumed per calendar month across every feature. When exceeded, AI features fall back to non-AI paths and surface a banner. Set 0 for no cap.',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const PROVIDER_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'openai_api_key',
|
||||
label: 'OpenAI API key',
|
||||
description:
|
||||
'Used by Receipt OCR fallback and (future) berth-PDF AI parse. Stored AES-encrypted at rest; the field shows blank after save.',
|
||||
type: 'password',
|
||||
placeholder: 'sk-…',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'openai_default_model',
|
||||
label: 'Default OpenAI model',
|
||||
description: 'Used when a feature does not specify an explicit model.',
|
||||
type: 'select',
|
||||
defaultValue: 'gpt-4o-mini',
|
||||
options: [
|
||||
{ value: 'gpt-4o-mini', label: 'gpt-4o-mini — cheap, fast, vision-capable' },
|
||||
{ value: 'gpt-4o', label: 'gpt-4o — full-strength multimodal' },
|
||||
{ value: 'gpt-4-turbo', label: 'gpt-4-turbo — legacy text reasoning' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface FeatureLink {
|
||||
href: string;
|
||||
icon: typeof Bot;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const FEATURE_LINKS: FeatureLink[] = [
|
||||
{
|
||||
href: '../berth-pdf-parser',
|
||||
icon: FileText,
|
||||
title: 'Berth PDF parser',
|
||||
description:
|
||||
'Three-tier AcroForm → OCR → AI pipeline. The AI pass costs tokens; reps invoke it manually when OCR confidence is low.',
|
||||
},
|
||||
{
|
||||
href: '../recommender',
|
||||
icon: Brain,
|
||||
title: 'Berth recommender',
|
||||
description:
|
||||
'Rule-based today; future versions will optionally use embeddings for soft preference matching. AI use is gated by the master switch above.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AiAdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="AI configuration"
|
||||
description="One place to manage every AI-using feature. Provider credentials and the master AI switch live here; per-feature thresholds remain in their dedicated pages, linked below."
|
||||
eyebrow="ADMIN"
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Master controls"
|
||||
description="Hard kill switch + budget guardrails covering every AI surface in this port."
|
||||
fields={MASTER_FIELDS}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Provider credentials"
|
||||
description="Shared API keys used by AI-enabled features. Per-feature pages can override the model on a feature-by-feature basis."
|
||||
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">
|
||||
<Bot className="h-4 w-4" /> Per-feature settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Feature-specific tuning lives on each feature's admin page. They all read the
|
||||
master switch + provider credentials configured above.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURE_LINKS.map((f) => (
|
||||
<Link
|
||||
key={f.href}
|
||||
href={f.href as never}
|
||||
className="rounded-md border bg-card p-3 hover:border-primary transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<f.icon className="h-4 w-4 text-muted-foreground" />
|
||||
{f.title}
|
||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function BackupManagementPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Backup Management</h1>
|
||||
<p className="text-muted-foreground">Manage system backups and restoration</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Backup & Restore"
|
||||
eyebrow="ADMIN"
|
||||
description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore."
|
||||
/>
|
||||
<BackupAdminPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ReconcileQueue } from '@/components/admin/reconcile-queue';
|
||||
|
||||
export default function ReconcileBerthsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Berth reconciliation queue"
|
||||
description="Berths flipped manually to Under Offer or Sold without a backing interest. Run the catch-up wizard on each row to create the deal, attach docs, and clear the manual flag."
|
||||
/>
|
||||
<ReconcileQueue />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,30 @@ import {
|
||||
SettingsFormCard,
|
||||
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;">
|
||||
<tr>
|
||||
<td align="center" style="padding:16px 0;">
|
||||
<a href="https://example.com" style="text-decoration:none;color:#1e293b;font-family:Arial,sans-serif;font-size:14px;font-weight:600;">
|
||||
Your brand name
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
const DEFAULT_EMAIL_FOOTER_HTML = `<!-- Optional sub-body footer -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px 0;color:#64748b;font-family:Arial,sans-serif;font-size:12px;">
|
||||
© ${new Date().getFullYear()} Your Company ·
|
||||
<a href="https://example.com" style="color:#64748b;">Visit our website</a> ·
|
||||
<a href="mailto:hello@example.com" style="color:#64748b;">hello@example.com</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
@@ -14,11 +38,11 @@ const FIELDS: SettingFieldDef[] = [
|
||||
},
|
||||
{
|
||||
key: 'branding_logo_url',
|
||||
label: 'Logo URL',
|
||||
label: 'Logo',
|
||||
description:
|
||||
'Public HTTPS URL of the logo used in email headers and the branded auth shell. Recommended size: 240×80 PNG with transparent background.',
|
||||
type: 'string',
|
||||
placeholder: 'https://example.com/logo.png',
|
||||
'Used in email headers and the branded auth shell. Recommended: square PNG with transparent background.',
|
||||
type: 'image-upload',
|
||||
imageAspect: 1,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
@@ -31,9 +55,11 @@ const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'branding_email_header_html',
|
||||
label: 'Email header HTML',
|
||||
description: 'Optional HTML rendered above each email body. Leave blank to use the default.',
|
||||
description:
|
||||
'Optional HTML rendered above each email body. Leave blank to use the default. Tap "Insert default" to start from the baseline template.',
|
||||
type: 'html',
|
||||
defaultValue: '',
|
||||
defaultTemplate: DEFAULT_EMAIL_HEADER_HTML,
|
||||
},
|
||||
{
|
||||
key: 'branding_email_footer_html',
|
||||
@@ -41,19 +67,17 @@ const FIELDS: SettingFieldDef[] = [
|
||||
description: 'Optional HTML rendered at the very bottom of each email (above the signature).',
|
||||
type: 'html',
|
||||
defaultValue: '',
|
||||
defaultTemplate: DEFAULT_EMAIL_FOOTER_HTML,
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandingSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Branding</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Logo, primary color, app name, and email header/footer HTML used by the branded auth shell
|
||||
and outgoing email templates.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Branding"
|
||||
description="Logo, primary color, app name, and email header/footer HTML used by the branded auth shell and outgoing email templates."
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="Identity"
|
||||
description="App name, logo, and primary color."
|
||||
@@ -64,6 +88,7 @@ export default function BrandingSettingsPage() {
|
||||
description="HTML fragments rendered around every transactional email."
|
||||
fields={FIELDS.slice(3)}
|
||||
/>
|
||||
<PdfLogoUploader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx
Normal file
21
src/app/(dashboard)/[portSlug]/admin/brochures/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel';
|
||||
|
||||
/**
|
||||
* Per-port admin page for managing brochures (Phase 7 §5.8).
|
||||
*
|
||||
* Lists brochures, lets per-port admins upload new versions via direct-to-
|
||||
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
|
||||
* body-size limit — see §11.1), and toggle the default flag.
|
||||
*/
|
||||
export default function BrochuresAdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Brochures"
|
||||
description="Port-wide marketing PDFs available to the sales send-out flow. The default brochure is the one /clients picker pre-selects."
|
||||
/>
|
||||
<BrochuresAdminPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +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: '',
|
||||
@@ -20,6 +25,91 @@ const API_FIELDS: SettingFieldDef[] = [
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_api_version_override',
|
||||
label: 'API version',
|
||||
description:
|
||||
'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.13.x (default, stable)' },
|
||||
{ value: 'v2', label: 'v2 — Documenso 2.x (envelope, recommended for new ports)' },
|
||||
],
|
||||
defaultValue: 'v1',
|
||||
},
|
||||
];
|
||||
|
||||
const SIGNER_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'documenso_developer_name',
|
||||
label: 'Developer signer — name',
|
||||
description:
|
||||
'The party who signs after the client (typically the marina developer or owner). Used as the static "developer" recipient in templated documents (EOI). Was hardcoded as "David Mizrahi" in the legacy single-tenant system.',
|
||||
type: 'string',
|
||||
placeholder: 'David Mizrahi',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_developer_email',
|
||||
label: 'Developer signer — email',
|
||||
description: 'Email used to send the developer signing request via Documenso.',
|
||||
type: 'string',
|
||||
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',
|
||||
description:
|
||||
'The final approver who signs after the developer (typically a sales/legal lead). Was hardcoded as "Abbie May" in the legacy system.',
|
||||
type: 'string',
|
||||
placeholder: 'Abbie May',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'documenso_approver_email',
|
||||
label: 'Approver — email',
|
||||
description: 'Email used to route the final approval signing request.',
|
||||
type: 'string',
|
||||
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[] = [
|
||||
@@ -43,18 +133,241 @@ const EOI_FIELDS: SettingFieldDef[] = [
|
||||
],
|
||||
defaultValue: 'documenso-template',
|
||||
},
|
||||
{
|
||||
key: 'eoi_send_mode',
|
||||
label: 'Initial signing-invitation email behaviour',
|
||||
description:
|
||||
'Auto = the system sends our branded "please sign" email immediately when an EOI/contract/reservation is generated. Manual = the document is generated and the signing URL appears in the UI; a rep clicks "Send invitation" to dispatch. Auto is the lower-friction option for high-volume teams; manual lets reps review before sending. Applies to all document types, not just EOI.',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'manual', label: 'Manual (rep clicks Send after generation)' },
|
||||
{ value: 'auto', label: 'Auto (send branded email on generate)' },
|
||||
],
|
||||
defaultValue: 'manual',
|
||||
},
|
||||
];
|
||||
|
||||
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-interest upload-and-place-fields flow instead (the typical path for contracts, since they are usually drafted custom per client).',
|
||||
type: 'string',
|
||||
placeholder: '',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
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 interest.',
|
||||
type: 'string',
|
||||
placeholder: '',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
const EMBED_FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'embedded_signing_host',
|
||||
label: 'Embedded signing host',
|
||||
description:
|
||||
"Origin of the public site that hosts the embedded Documenso signing pages. Outbound emails wrap raw Documenso signing URLs into {host}/sign/<type>/<token> so clients sign on your branded page rather than Documenso's domain. Leave blank to fall back to the app URL. Marketing-website pattern: https://portnimara.com",
|
||||
type: 'string',
|
||||
placeholder: 'https://portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Documenso & EOI</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
API credentials and default EOI generation pathway. Use the test-connection button to
|
||||
verify a saved configuration before relying on it.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Documenso & EOI"
|
||||
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"
|
||||
@@ -63,11 +376,35 @@ 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."
|
||||
fields={SIGNER_FIELDS}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="EOI generation"
|
||||
description="Default pathway and template used when an interest's EOI is generated."
|
||||
description="Default pathway, template, and email behaviour when an interest's EOI is generated."
|
||||
fields={EOI_FIELDS}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Contract & reservation templates (optional)"
|
||||
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}
|
||||
/>
|
||||
|
||||
<SettingsFormCard
|
||||
title="Embedded signing"
|
||||
description="Where the public-facing branded signing pages live. The CRM rewrites Documenso signing URLs to point here when sending invitation and reminder emails."
|
||||
fields={EMBED_FIELDS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/admin/duplicates/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/duplicates/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DuplicatesReviewQueue } from '@/components/admin/duplicates/duplicates-review-queue';
|
||||
|
||||
export default function DuplicatesAdminPage() {
|
||||
return <DuplicatesReviewQueue />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EmailTemplatesAdmin } from '@/components/admin/email-templates-admin';
|
||||
|
||||
export default function EmailTemplatesPage() {
|
||||
return <EmailTemplatesAdmin />;
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
SettingsFormCard,
|
||||
type SettingFieldDef,
|
||||
} 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[] = [
|
||||
{
|
||||
@@ -28,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',
|
||||
@@ -70,7 +57,7 @@ const FIELDS: SettingFieldDef[] = [
|
||||
{
|
||||
key: 'smtp_pass_override',
|
||||
label: 'SMTP password override',
|
||||
description: 'Optional. Stored in plain text — only set when overriding env credentials.',
|
||||
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
@@ -79,23 +66,22 @@ const FIELDS: SettingFieldDef[] = [
|
||||
export default function EmailSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Email Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Per-port outgoing email configuration. SMTP credentials and the From address default to
|
||||
environment variables when these fields are blank.
|
||||
</p>
|
||||
</div>
|
||||
<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. 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>
|
||||
);
|
||||
}
|
||||
|
||||
246
src/app/(dashboard)/[portSlug]/admin/errors/[requestId]/page.tsx
Normal file
246
src/app/(dashboard)/[portSlug]/admin/errors/[requestId]/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { ArrowLeft, Copy, Wrench } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Route } from 'next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ERROR_CODES, isErrorCode } from '@/lib/error-codes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { ErrorEvent } from '@/lib/db/schema/system';
|
||||
import type { LikelyCulprit } from '@/lib/error-classifier';
|
||||
|
||||
interface DetailResponse {
|
||||
data: ErrorEvent & { likelyCulprit: LikelyCulprit | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail view for a single captured error. Shows everything an admin
|
||||
* needs to triage:
|
||||
*
|
||||
* - Request shape: method, path, status, duration, who fired it
|
||||
* - Error: name, message, full stack head, (sanitized) request body
|
||||
* - Likely-culprit hint: heuristic-driven plain-English root-cause
|
||||
* - Raw metadata: pg SQLSTATE codes, internal-message debug strings
|
||||
*/
|
||||
export default function ErrorEventDetailPage() {
|
||||
const params = useParams<{ portSlug: string; requestId: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const requestId = params?.requestId ?? '';
|
||||
|
||||
const query = useQuery<DetailResponse>({
|
||||
queryKey: ['admin', 'error-events', requestId],
|
||||
queryFn: () => apiFetch<DetailResponse>(`/api/v1/admin/error-events/${requestId}`),
|
||||
enabled: Boolean(requestId),
|
||||
});
|
||||
|
||||
function copy(text: string, label: string) {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
||||
void navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied`);
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const event = query.data?.data;
|
||||
if (!event) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
Error event not found. It may have been pruned or you may not have access.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/${portSlug}/admin/errors` as Route}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back to error list
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-2xl font-bold">Error {requestId.slice(0, 8)}…</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
event.statusCode >= 500
|
||||
? 'border-destructive/40 text-destructive'
|
||||
: 'border-amber-300 text-amber-800'
|
||||
}
|
||||
>
|
||||
{event.statusCode}
|
||||
</Badge>
|
||||
{event.likelyCulprit && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Wrench className="h-3 w-3" />
|
||||
{event.likelyCulprit.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => copy(requestId, 'Reference ID')}>
|
||||
<Copy className="mr-1.5 h-3 w-3" />
|
||||
Copy ID
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{event.likelyCulprit && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4" /> Likely culprit
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
<p className="font-medium">{event.likelyCulprit.label}</p>
|
||||
<p className="text-muted-foreground mt-1">{event.likelyCulprit.hint}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Subsystem: <code className="font-mono">{event.likelyCulprit.subsystem}</code>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* If the captured error has a registered code on its metadata,
|
||||
* surface the canonical user-facing message + status from the
|
||||
* registry so the admin can compare what the user saw to what
|
||||
* the system actually did. */}
|
||||
{(() => {
|
||||
const meta = (event.metadata ?? {}) as Record<string, unknown>;
|
||||
const code = typeof meta.code === 'string' ? meta.code : null;
|
||||
if (!code || !isErrorCode(code)) return null;
|
||||
const def = ERROR_CODES[code];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Error code</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{def.status}</Badge>
|
||||
<code className="font-mono text-xs font-semibold">{code}</code>
|
||||
</div>
|
||||
<p className="mt-2">{def.userMessage}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Compare to the message the user saw in their toast.{' '}
|
||||
<Link
|
||||
href={`/${portSlug}/admin/errors/codes` as Route}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
All codes →
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Request</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<KV label="Method" value={event.method} />
|
||||
<KV label="Path" value={event.path} mono />
|
||||
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
|
||||
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '—'} />
|
||||
<KV label="Port" value={event.portId ?? '(none)'} mono />
|
||||
<KV label="User" value={event.userId ?? '(none)'} mono />
|
||||
<KV label="IP" value={event.ipAddress ?? '—'} mono />
|
||||
<KV label="User agent" value={event.userAgent ?? '—'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<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 wrap-break-word">
|
||||
{event.errorMessage ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
{event.errorStack && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Stack (truncated)</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copy(event.errorStack ?? '', 'Stack')}
|
||||
>
|
||||
<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 wrap-break-word">
|
||||
{event.errorStack}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{event.requestBodyExcerpt && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Request body (sanitized, max 1 KB)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{event.metadata !== null &&
|
||||
typeof event.metadata === 'object' &&
|
||||
Object.keys(event.metadata as Record<string, unknown>).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">Metadata</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="overflow-auto rounded bg-muted p-2 text-xs font-mono">
|
||||
{JSON.stringify(event.metadata, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KV({ label, value, mono }: { label: string; value: string | null; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '—'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
src/app/(dashboard)/[portSlug]/admin/errors/codes/page.tsx
Normal file
134
src/app/(dashboard)/[portSlug]/admin/errors/codes/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ArrowLeft, BookOpen, Search } from 'lucide-react';
|
||||
|
||||
import type { Route } from 'next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ERROR_CODES } from '@/lib/error-codes';
|
||||
|
||||
/**
|
||||
* Error-code reference page surfaced inside the admin section so an
|
||||
* admin investigating a captured error_events row can flip to this
|
||||
* tab, look up the code the user reported, and read the canonical
|
||||
* plain-language meaning + status code without leaving the app.
|
||||
*
|
||||
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
|
||||
* automatically — adding an entry to the registry adds a row here.
|
||||
*/
|
||||
export default function ErrorCodeReferencePage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = Object.entries(ERROR_CODES) as Array<
|
||||
[string, (typeof ERROR_CODES)[keyof typeof ERROR_CODES]]
|
||||
>;
|
||||
if (!search.trim()) return all;
|
||||
const q = search.trim().toLowerCase();
|
||||
return all.filter(
|
||||
([code, def]) => code.toLowerCase().includes(q) || def.userMessage.toLowerCase().includes(q),
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
// Group by domain prefix (the part before the first underscore) so
|
||||
// the table reads naturally — Expenses, Berths, Storage, etc.
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
const prefix = entry[0].split('_')[0] ?? 'OTHER';
|
||||
const bucket = groups.get(prefix) ?? [];
|
||||
bucket.push(entry);
|
||||
groups.set(prefix, bucket);
|
||||
}
|
||||
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [entries]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/${portSlug}/admin/errors` as Route}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back to error inspector
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" /> Error code reference
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Every error code the platform can return, with its HTTP status and the plain-language
|
||||
message a user sees. Codes are stable identifiers — once shipped, they never get
|
||||
renamed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search code or message…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
No codes match "{search}".
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{grouped.map(([prefix, items]) => (
|
||||
<Card key={prefix}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{prefix}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{items.map(([code, def]) => (
|
||||
<div key={code} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
def.status >= 500
|
||||
? 'border-destructive/40 text-destructive'
|
||||
: def.status >= 400
|
||||
? 'border-amber-300 text-amber-800'
|
||||
: 'border-muted'
|
||||
}
|
||||
>
|
||||
{def.status}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-xs font-semibold">{code}</p>
|
||||
<p className="text-sm mt-0.5">{def.userMessage}</p>
|
||||
{'hint' in def && typeof def.hint === 'string' && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{def.hint}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/app/(dashboard)/[portSlug]/admin/errors/page.tsx
Normal file
157
src/app/(dashboard)/[portSlug]/admin/errors/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { AlertTriangle, BookOpen, Search, Wrench } from 'lucide-react';
|
||||
|
||||
import type { Route } from 'next';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { classifyError } from '@/lib/error-classifier';
|
||||
import type { ErrorEvent } from '@/lib/db/schema/system';
|
||||
|
||||
interface ListResponse {
|
||||
data: ErrorEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Super-admin error inspector.
|
||||
*
|
||||
* Shows the most recent captured 5xx errors with: when, where (HTTP
|
||||
* method + path), what (error name + message), and a heuristic
|
||||
* "likely culprit" badge driven by `classifyError`. Click into any
|
||||
* row for the full stack + body excerpt + raw metadata.
|
||||
*/
|
||||
export default function AdminErrorsPage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
const query = useQuery<ListResponse>({
|
||||
queryKey: ['admin', 'error-events', { statusFilter }],
|
||||
queryFn: () => {
|
||||
const search = new URLSearchParams();
|
||||
if (statusFilter) search.set('statusCode', statusFilter);
|
||||
return apiFetch<ListResponse>(
|
||||
`/api/v1/admin/error-events${search.toString() ? `?${search.toString()}` : ''}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const events = query.data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Error inspector"
|
||||
description="Captured 5xx errors. Click any row for the full stack, request body excerpt, and likely culprit."
|
||||
actions={
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/${portSlug}/admin/errors/codes` as Route}>
|
||||
<BookOpen className="mr-1.5 h-4 w-4" />
|
||||
Code reference
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Search className="h-4 w-4" /> Filters
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="status">
|
||||
Status code
|
||||
</label>
|
||||
<Input
|
||||
id="status"
|
||||
placeholder="e.g. 500"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value.replace(/\D/g, ''))}
|
||||
className="h-8 w-32"
|
||||
/>
|
||||
</div>
|
||||
{statusFilter && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => setStatusFilter('')}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="No captured errors"
|
||||
description="Nothing has hit a 5xx in the selected window. That's a good thing."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border divide-y">
|
||||
{events.map((event) => {
|
||||
const culprit = classifyError(event);
|
||||
return (
|
||||
<Link
|
||||
key={event.requestId}
|
||||
href={`/${portSlug}/admin/errors/${event.requestId}` as Route}
|
||||
className="flex items-start gap-3 p-3 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
event.statusCode >= 500
|
||||
? 'border-destructive/40 text-destructive'
|
||||
: 'border-amber-300 text-amber-800'
|
||||
}
|
||||
>
|
||||
{event.statusCode}
|
||||
</Badge>
|
||||
<span className="text-xs font-mono uppercase text-muted-foreground">
|
||||
{event.method}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate">{event.path}</span>
|
||||
{culprit && (
|
||||
<Badge variant="secondary" className="gap-1 text-xs">
|
||||
<Wrench className="h-3 w-3" />
|
||||
{culprit.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{event.errorName ? `${event.errorName}: ` : ''}
|
||||
{event.errorMessage ?? '(no message)'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(new Date(event.createdAt), { addSuffix: true })} ·{' '}
|
||||
{format(new Date(event.createdAt), 'MMM d HH:mm:ss')} · ID{' '}
|
||||
<code className="font-mono">{event.requestId.slice(0, 12)}…</code>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,75 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export default function DataImportPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Data Import</h1>
|
||||
<p className="text-muted-foreground">Import data from external sources</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 4</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Data import"
|
||||
description="What you can import today and what an in-app importer will look like."
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 mt-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Available imports today</CardTitle>
|
||||
<CardDescription>Run from the command line until the UI catches up.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p>
|
||||
<strong>Berths from NocoDB:</strong>
|
||||
</p>
|
||||
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
|
||||
pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug port-nimara
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Idempotent. Skips rows where <code>updated_at > last_imported_at</code> unless
|
||||
you pass <code>--force</code>. Add <code>--update-snapshot</code> to also rewrite{' '}
|
||||
<code>src/lib/db/seed-data/berths.json</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
<strong>Storage backend migration:</strong>
|
||||
</p>
|
||||
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
|
||||
pnpm tsx scripts/migrate-storage.ts
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Run after switching <code>system_settings.storage_backend</code> in System Settings.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
<strong>Seed (rebuild dev fixtures):</strong>
|
||||
</p>
|
||||
<pre className="bg-muted/40 rounded-md p-2 text-xs mt-1 overflow-auto">
|
||||
pnpm db:seed
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>What this page will become</CardTitle>
|
||||
<CardDescription>Planned UI for self-serve imports.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Drag-and-drop CSV / XLSX upload with column-mapping UI.</li>
|
||||
<li>Dry-run preview that shows new vs. matched-existing rows before commit.</li>
|
||||
<li>Conflict-resolution choices (skip, update, dedup-by-email) per import type.</li>
|
||||
<li>Per-port import history with rollback.</li>
|
||||
<li>Templates for clients, yachts, companies, berths, reservations, expenses.</li>
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground pt-2">
|
||||
Imports run against the BullMQ <code>import</code> queue (concurrency 1) so partial
|
||||
failures don’t leave the database half-loaded.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
|
||||
|
||||
export default function InquiriesPage() {
|
||||
return <InquiryInbox />;
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { InvitationsManager } from '@/components/admin/invitations/invitations-manager';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
export default function InvitationsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Invitations</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a single-use invitation to a new CRM user. The recipient sets their own password via
|
||||
the link in the email.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Invitations"
|
||||
description="Send a single-use invitation to a new CRM user. The recipient sets their own password via the link in the email."
|
||||
/>
|
||||
<InvitationsManager />
|
||||
</div>
|
||||
);
|
||||
|
||||
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
|
||||
/**
|
||||
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
|
||||
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
|
||||
*/
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, session.user.id),
|
||||
});
|
||||
|
||||
if (!profile?.isSuperAdmin) {
|
||||
redirect(`/${portSlug}/dashboard`);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user