Commit Graph

251 Commits

Author SHA1 Message Date
9a5ba87d6c fix(integration): webhook v2 events, storage migrate, test theatre
- F1: DOCUMENT_DECLINED handler (v2 Decline vs Reject) — routes to same
  handler as DOCUMENT_REJECTED until product refines downstream UX
- Add RECIPIENT_VIEWED / RECIPIENT_SIGNED v2-alias cases with telemetry
  logging so we see when v2 deployments emit them
- D1: populate TABLES_WITH_STORAGE_KEYS (files, berth_pdf_versions,
  brochure_versions, gdpr_exports) — was an empty list, migrated 0 files
- MinIO putObject/getObject/statObject/removeObject socket timeout wrapper
  to prevent worker hangs on TCP blackhole (30s deadline)
- E1: convert test.skip on smoke-setup infra failure to throw new Error
  so green-skipped silence becomes a real test failure (Playwright
  doesn't expose vitest's expect.fail)
- Regression tests: folderId='' → null transform, applyEntityRestoredSuffix
  no-op (never-archived), syncEntityFolderName collision loop past (2)

Note: matching .env.example documentation (D2 — bare DOCUMENSO_API_URL,
DOCUMENSO_API_VERSION, MINIO_AUTO_CREATE_BUCKET, DOCUMENSO_TEMPLATE_ID_EOI,
recipient role id vars) prepared but not committed — pre-commit hook
blocks .env*. Apply manually via the separate .env workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:02:26 +02:00
955911302b fix(folders): logging, files-rescue, hard-delete wiring, audit logs
- A6: logger import + warn calls in document-folders.service.ts
- G-C1: re-parent files (not just documents) in deleteFolderSoftRescue
- A4: importer sets files.folder_id (was only setting documents.folder_id)
- A7 + G-C3: demote system folder + nullify scratchpadNotes in client-hard-delete
- Defense-in-depth portId on folder-move UPDATE
- Audit logs for createFolder, syncEntityFolderName, archive/restore suffix
- portId in companies/yachts archive log context
- Row-count telemetry in backfill CLI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:57:42 +02:00
c761b4b911 fix(documents): idempotency, perf, contract pipeline, observability
- A1: idempotency gate in handleDocumentCompleted (prevents duplicate files on Documenso retry)
- A3: LEFT JOIN port_id move to outer WHERE (uses idx_docs_signed_file_id)
- G-C5: contract_sent / contract_signed auto-advance triggers in sendDocument + handleDocumentCompleted
- 0-byte signed PDF guard before storage.put
- portId in outer catch + poll worker
- Sanitize storagePath/storageBucket in aggregated files API
- Audit log for handleDocumentCompleted file insert
- Replace em-dashes in aggregated group labels with colons
- G-I6: delete orphaned hub-counts route + getHubTabCounts service fn

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:56:46 +02:00
c0e5af8b92 fix(sales): wire missing berth-rule triggers + portal company-billed invoices
- G-C4: deposit_received in invoices.ts
- G-C4 + G-I2: interest_archived + notifyNextInLine in archiveInterest
- G-C4: interest_completed in setInterestOutcome
- G-C4: berth_unlinked in removeInterestBerth
- G-I5: portal invoices include billingEntityType='company' when client is the director

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:53:10 +02:00
1b00c8a7a2 feat(db): tighten chk_system_folder_shape, add recommender FK + composite indexes
- Fix A5: chk_system_folder_shape NULL escape
- Fix Audit 17 G-I4: berthRecommendations.interestId FK with cascade
- Add (port_id, client_id) / (port_id, company_id) / (port_id, yacht_id) composite indexes on files + documents for aggregated-projection performance

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:47:52 +02:00
0804944647 fix(documents): folderId=empty-string normalises to null at validator
The hub UI sends folderId='' when the user picks "root-only" in the
folder sidebar. The Zod validator was accepting it as a string and
the service then ran eq(folderId, '') instead of isNull(folderId),
returning zero results.

Adding a .transform on folderId converts empty string to null at the
boundary so the service receives the expected nullable shape. Pre-
existing bug from Wave 11.B that the hub rebuild made more visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:10:19 +02:00
0e8feb1073 chore: prettier format pass on branch files
Auto-format all files modified during the documents-hub-split feature
branch that were not yet aligned with the project's Prettier config
(single quotes, semicolons, trailing commas).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:01:47 +02:00
a4c49f5e5a fix(documents): surface signedFromDocumentId + hub cleanup
Three follow-ups from Task 15 code review:

1. (Important) The aggregated files API now LEFT JOINs against
   documents to surface signedFromDocumentId per file row. The
   "view signing details" button on EntityFolderView's Files
   section now passes the workflow id to SigningDetailsDialog
   instead of the file id. Previously the button always 404'd
   and the dialog hung in the loading state. Drops the v1
   filename-prefix heuristic.

2. (Minor) Drop dead initialTab prop + DocumentsHubTab import —
   leftover from the pre-refactor tab strip.

3. (Minor) FlatFolderListing remounts on folder switch via a key
   prop, restoring the pre-refactor typeFilter reset behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:44:48 +02:00
ae3f483cb6 feat(documents): hide completed workflows from folder views
When listDocuments is called with folderId set (including folderId=null
for root-only), exclude status='completed' rows. The signed-PDF file
appears in the Files section with a "view signing details" link; the
workflow row would just be noise alongside the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:14:51 +02:00
c9f0bdc687 fix(documents): tighten cross-port test + refine paths + signing-details coverage
Three follow-ups from Task 9 code review:
1. Cross-port isolation test now explicitly asserts the other-port
   file's id is absent from the aggregated result (previously only
   checked .length > 0, which would pass even with leakage).
2. Refine errors now carry path fields so frontend field-level error
   display can target the right form input (matches createDocumentSchema
   pattern in the same validators module).
3. Add a service-composition test for the signing-details route's
   workflow+signers+events shape — closes the coverage gap for the
   thin Promise.all combinator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:13:27 +02:00
dec54806cb feat(documents): entity-aggregated query params + signing-details API
GET /api/v1/files?entityType=client&entityId=… and the same params on
the documents route return the owner-aggregated projection
{ groups: [{ label, source, files|workflows, total }] }. folderId
remains for direct-folder listing; the two modes are mutually
exclusive (zod refine).

GET /api/v1/documents/[id]/signing-details returns
{ workflow, signers, events } for the "view signing details" dialog
on signed-PDF rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:06:49 +02:00
d2b0d42e84 fix(documents): tighten aggregation — filter ended memberships + symmetry
Four follow-ups from Task 8 code review:
1. Aggregation now filters companyMemberships to active rows only
   (isNull(endDate)) on both client→companies and company→clients
   joins. Previously a rep who left a company 2y ago would still
   see that company's files in their aggregated view. Brings this
   service in line with the 8 other call sites in the codebase that
   already filter on endDate.
2. Move collectRelatedEntities import to the top of
   documents.service.ts — was wedged mid-file.
3. listInflightWorkflowsAggregatedByEntity now calls
   assertEntityInPort for symmetry with the files version. Cross-
   port reads short-circuit early instead of executing N empty
   port-scoped queries.
4. Add a cross-port leakage regression test for the workflow
   projection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:02:33 +02:00
3037d832c6 feat(documents): owner-aggregated projection (files + workflows)
listFilesAggregatedByEntity walks the relationship graph (symmetric
reach: clients <-> companies via memberships, <-> yachts via current
ownership) and groups results by source: DIRECTLY ATTACHED + FROM
COMPANY/YACHT/CLIENT. File-FK snapshot is the source of truth so
historical files survive yacht-ownership transfer. Each group caps at
20 rows + a total for "Show all (N)" drill-through. Defense-in-depth
port_id filter at every join.

listInflightWorkflowsAggregatedByEntity reuses the same graph walk
for in-flight signing workflows (draft/sent/partially_signed only).
Completed workflows are hidden — they surface via their signed-PDF
file row instead.

applyEntityFkFromFolder auto-sets the matching entity FK on the file
row when the upload target is a system-managed entity subfolder (E8).
Wired into uploadFile; validator extended with folderId field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:54:23 +02:00
8e2e2ea113 fix(documents): tighten owner resolution + cover company/yacht paths
Three follow-ups from Task 7 code review:
1. Drop the dead interest.yachtId fallback branch. interests.clientId
   is NOT NULL so the yacht branch was unreachable. Comment explains
   the schema constraint so the branch can be re-added if that
   constraint is ever relaxed.
2. Add defense-in-depth port_id filter to the interests lookup
   inside resolveDocumentOwner (matches CLAUDE.md convention and
   every other interests query in this file).
3. Add two integration test cases for direct-company and direct-yacht
   owner resolution — closes the coverage gap where the signed-file
   row's companyId/yachtId columns are populated for the first time
   in this commit chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:48:44 +02:00
ee6e3f3f3f feat(documents): auto-deposit signed PDFs into entity folders
handleDocumentCompleted resolves the workflow owner via the Owner-wins
chain (document.clientId → companyId → yachtId, then interest.clientId
→ yachtId), ensures the matching entity subfolder, and sets
files.folder_id + the matching entity FK on the signed file row.
Falls back to root (folder_id=null) when no owner is resolvable.
ensureEntityFolder failures are logged at warn level — the signed
PDF always lands; the backfill script heals missing folders.

The interest fallback omits the company branch because interests
table has no companyId column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:41:47 +02:00
0412107d86 fix(documents): tighten archive/restore idempotency + document fire-and-forget
Three follow-ups from Task 6 code review:
1. applyEntityArchivedSuffix short-circuits when the folder is already
   archived — prevents archivedAt drift on backfill replay.
2. applyEntityRestoredSuffix short-circuits when the folder was never
   archived — matches the docstring's "no-op" claim.
3. Inline comment on archiveClient's fire-and-forget hook documents
   why Task 6 uses void (archive UI doesn't depend on folder sync)
   while Task 5 uses await (rename should be visible to the next
   read).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:38:18 +02:00
4c5dc7ec17 feat(documents): entity-folder archive / restore / demote helpers
applyEntityArchivedSuffix stamps " (archived)" + archived_at on the
entity subfolder so the UI mutes it and auto-deposit halts. Restore
is the inverse. demoteSystemFolderOnEntityDelete flips
system_managed=false, appends " (deleted)", and clears the entity FK
so the partial unique index releases the slot — orphaned files
retain their entity FK snapshots and surface in the rep's clean-up
view.

All three helpers are best-effort from the entity-side hooks; folder
errors are logged at warn level but do not fail the entity-update
operation. UPDATE WHERE clauses include port_id (defense-in-depth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:34:02 +02:00
3b34b41989 fix(documents): syncEntityFolderName defense-in-depth + log level
Two follow-ups from code review:
1. The UPDATE in the retry loop now scopes by both id and port_id so
   it matches every other mutation in document-folders.service.ts and
   honours the CLAUDE.md defense-in-depth pattern.
2. The three entity-rename hooks now log at warn level (not error) —
   a missed folder rename is best-effort cosmetic drift, not a paging
   incident. Matches the existing convention used elsewhere in the
   codebase for non-fatal background work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:30:19 +02:00
86a6944d1c feat(documents): syncEntityFolderName + entity-rename hooks
Per-entity subfolder names mirror the entity's current display string.
Wired into updateClient / updateCompany / updateYacht; runs only when
the name field changes. Best-effort (logged + swallowed) so a folder-
sync error never fails an entity update. Preserves the (archived)
suffix when present; skips entirely when the folder has been demoted
to (deleted) — the rep owns the name at that point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:25:16 +02:00
64d0ae540b feat(documents): block rename/move/delete on system folders
assertNotSystemManaged centralises the guard so the three mutation
paths surface identical ConflictError shapes. System roots and per-
entity subfolders are immutable through the rep-facing API; the only
way for system_managed to flip back to false is the entity-hard-
delete demotion path (next task).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:20:21 +02:00
2f3200764a feat(documents): ensureEntityFolder (concurrent-safe + suffix on collision)
Idempotent per-entity subfolder creation under the matching system
root. Fast-path SELECT short-circuits the common case. Inserts race
safely via uniq_document_folders_entity (partial unique on
port_id+entity_type+entity_id) — the loser re-SELECTs the winner's
row. Sibling-name collisions between two entities with the same
display name append (2), (3), … to the new folder; existing folders
never rename. Exports EntityType for use by downstream tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:14:11 +02:00
a23a9862cc docs(documents): clarify ensureSystemRoots safety invariants
Adds inline comments explaining (a) why no-target onConflictDoNothing
is safe for root inserts (the only unique index that can fire on a
root row is uniq_document_folders_sibling_name; the partial entity
index excludes entity_id=NULL rows) and (b) why createPort doesn't
wrap the root bootstrap in a transaction (ensureSystemRoots is re-
runnable; the backfill script heals orphaned ports). Surfaces the
assumption that Task 3 (ensureEntityFolder) must not blindly copy
this pattern — it inserts with entity_id NOT NULL and needs an
explicit conflict target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:10:47 +02:00
b0831a6872 feat(documents): ensureSystemRoots + wire into createPort
Adds idempotent root-folder bootstrap (Clients/Companies/Yachts)
called on every port-init. ON CONFLICT DO NOTHING on the sibling-name
unique index prevents racing inserts; the re-SELECT returns the stable
row set in SYSTEM_ROOT_NAMES order. Same helper is invoked by the
backfill script in a later task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:06:41 +02:00
eee4f06737 fix(documents): correct 0051 migration header — backfill ships separately
Header comment said the migration backfills the structure; it doesn't.
Backfill is in scripts/backfill-document-folders.ts (Task 11) so the
schema change can deploy first and the data work runs idempotently
after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:03:53 +02:00
48f6fb94a7 feat(documents): schema for hub split + entity-folder lifecycle
Adds system_managed / entity_type / entity_id / archived_at to
document_folders for the three system roots (Clients/Companies/
Yachts) + per-entity auto-subfolders. Adds files.folder_id so a
file's home is a first-class field (not derived from storagePath
prefix). Partial unique index uniq_document_folders_entity dedupes
entity subfolders per port; chk_system_folder_shape pins the shape
of system rows. Migration is idempotent and ships without backfill —
the backfill script runs as a separate deploy step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:00:40 +02:00
5422f11747 chore: prettier formatter drift across recent commits
Prettier reformatting on files touched in the wave 11.B sequence —
markdown italics _underscore-style_, single-line conditionals, minor
whitespace fixes. No semantic changes. .env.example reformatting left
unstaged (blocked by pre-commit hook).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:57:37 +02:00
ef63e86fde feat(documents): importer for organized S3/filesystem buckets
One-shot script that walks an existing organized bucket tree, builds
matching document_folders rows mirroring the path, then inserts
documents + files rows pointing at the existing storage keys verbatim
— no path rewrite. For migrating from a legacy MinIO bucket whose
folder structure is already the source of truth.

Idempotency:
  • Folders: sibling-name unique index swallows duplicate creates;
    we reuse the row on ConflictError.
  • Documents: skipped when (port_id, fileStoragePath) already exists.

Adds StorageBackend.listByPrefix (recursive readdir on filesystem;
listObjectsV2 stream-drain on s3) — the first one-shot caller, not
a hot path. Pure parseImportPath helper extracted to its own module
and unit-tested for trailing slashes, empty intermediate segments,
prefix mismatch, and special-character folder names (8 tests).

Audit log per imported doc carries source='organized-bucket-importer',
storageKey, and folderSegments so the documents inspector can filter
on imports later.

CLI:
  pnpm tsx scripts/import-organized-documents.ts \\
      --port-slug <slug> \\
      --bucket-prefix "legacy-imports/" \\
      (--dry-run | --apply) [--uploaded-by <userId>]

Folds in Prettier post-hook drift on documents.service.ts +
download handler — same lint-staged formatting the earlier commits
already absorbed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:53:51 +02:00
e790ff708b feat(documents): path-style download URLs for rep-facing readability
Storage paths stay UUID-flat per the established CRM pattern (every
other content type — brochures, berth PDFs, invoices, reports,
templates, expense receipts — uses the same shape). The new
catch-all /api/v1/documents/[id]/download/[...slug] route serves
files keyed on doc id but rebuilds the slug from current state and
404s on mismatch — a hand-edited or stale link can't render the
wrong filename or fold a wrong-folder path into a forwarded URL.

URLs in shared links / browser tabs read like
'Deals 2026/Q1/contract.pdf' even though storage keys remain UUIDs.
listDocuments + getDocumentById now hydrate a `downloadUrl` field
per row (null when no file is attached yet) so UI consumers don't
reconstruct paths. Filename is batch-fetched via files-table join
to keep the query builder shape unchanged.

Tests: 5 integration cases — happy-path stream, wrong-folder slug,
wrong-filename slug, orphaned doc (no fileId), cross-port (tenancy
isolation). Storage backend swapped to a real FilesystemBackend in
a tempdir so the byte-streaming path is exercised end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:50:16 +02:00
cf8bbf3018 fix(documents): defense-in-depth port_id scope + invisible chevron a11y
- renameFolder/moveFolder UPDATE and deleteFolderSoftRescue DELETE now
  carry an explicit port_id predicate so the write is bounded to the
  same tenancy the pre-fetch verified, defending against future
  refactors that drop or reorder the ownership check.
- FolderRow's collapsed-children chevron is `invisible` for layout
  purposes, but it was still in the tab order with a misleading
  Expand/Collapse aria-label. Add aria-hidden + tabIndex=-1 when no
  children so keyboard users skip it.

Surfaced by post-implementation review (subagent code-review pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:50:02 +02:00
4556a03b8b feat(documents): wire folder sidebar + breadcrumb + In-progress tab
Documents hub now opens with the folder tree on the left and a
breadcrumb on top. Folder selection is its own state — undefined =
"All", null = "Root only", string = specific folder. Filter pushes
through to /api/v1/documents via folderId query param.

Drops the "Signature-based only" pill — it defaulted to true and
silently hid informational documents, which confused new reps. With
folders the rep organises by location, not by signature-vs-not.

Adds an "In progress" hub tab covering status IN (draft, sent,
partially_signed) for the everyday "what's in flight" view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:12:53 +02:00
a0ffa1baae feat(documents): folder filter on list + per-doc move endpoint
listDocuments accepts folderId (string | null | undefined) and
includeDescendants. folderId=null returns only docs at root;
includeDescendants=true expands the subtree via collectDescendantIds
(in-memory walk over the cached tree -- folder trees are small).

PATCH /api/v1/documents/[id]/folder moves a single document under
documents.manage_folders, with audit-log metadata { type: 'folder_move' }.
Bumping updatedAt is correct for per-doc moves because reps deliberately
acted on that document -- different semantics from the bulk soft-rescue
in Task 4.

createDocument accepts an optional folderId for the upcoming UI's
"create in current folder" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:03:25 +02:00
830ac39900 feat(documents): zod validators for folder CRUD
createFolderSchema, renameFolderSchema, moveFolderSchema,
moveDocumentToFolderSchema. Names: 1–200 chars, non-whitespace.
parentId/folderId nullable to allow root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:52:39 +02:00
4ec0004867 fix(documents): folder service · audit + portId + audit-log placement
Code-review followups on e9251a3:
- Move createAuditLog OUT of the deleteFolderSoftRescue transaction
  callback so a rolled-back transaction can't leave a phantom audit
  row. Pattern matches clients.service.ts, expense-dedup.service.ts.
- Add portId filter to the moveFolder ancestor-walk findFirst —
  defense-in-depth so corrupted parentId pointing at another port
  short-circuits the walk instead of silently traversing it.
- Drop updatedAt bump on rescued documents — folder rescue is an
  administrative storage op, not a content change; bumping made
  every rescued doc appear "recently modified" in list views.
- Add userId param + audit-log emission on renameFolder and
  moveFolder for parity with createFolder + deleteFolderSoftRescue.
  Tests updated to pass TEST_USER_ID as the new 4th arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:50:51 +02:00
e9251a399a feat(documents): folder service · rename + move + soft-rescue delete
renameFolder + moveFolder enforce sibling-name uniqueness via the
shared isSiblingNameConflict helper and reject cross-port leakage at
the service boundary. moveFolder walks the destination's ancestor
chain to refuse cycles before the write.

deleteFolderSoftRescue re-parents every child folder and document up
to the deleted folder's parent (or to root) inside a transaction,
then drops the folder row. Children never disappear silently — a
wrong click moves work up the tree, never deletes it. Audit-logged
with rescuedTo metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:41:25 +02:00
5c5ab49218 fix(documents): port-scope folder test cleanup + tighten parent-validation message
Code-review followups on 4b31f01:
- beforeEach now scopes the documentFolders cleanup to the test port
  via .where(eq(documentFolders.portId, portId)) so parallel suites
  don't wipe each other's fixtures.
- Cross-port parent guard message changed from "Parent folder not
  found in this port" (read like a 404) to "Invalid parent folder"
  to match the ValidationError type that already maps to 400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:36:31 +02:00
4b31f01a04 feat(documents): folder service · listTree + createFolder
In-memory tree build (single SELECT + JS nesting); the folder tree is
small enough that a recursive CTE buys nothing. Sibling-name conflict
maps the Postgres unique-index 23505 to a typed ConflictError so the
UI can render a clean toast. Cross-port parentId rejected at the
service boundary. Also adds document_folders to the global teardown
CTE so test ports can be cleaned up without FK violations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:30:56 +02:00
e6cf50fd46 feat(perms): add documents.manage_folders permission
Mirrors files.manage_folders. Gates create / rename / move / delete
of document folders, plus moving documents between folders. Reps with
documents.edit but not manage_folders can rename docs in place but
can't reorganise the tree. Admin + sales_manager get the perm by
default; sales_rep + viewer don't.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:23:22 +02:00
4a50bab389 fix(documents): wire folder Drizzle .references() + relations
Code-review followups on 5bed62d. Adds the missing .references()
on documents.folder_id (lazy AnyPgColumn form to forward-reference
documentFolders, which is declared later in the same file) so a
future db:generate run doesn't silently drift the schema. Adds
documentFoldersRelations and a folder leg on documentsRelations so
Task 2's service layer can use Drizzle's relational query API for
parent/children/documents traversal. Inline WHY comment on the
parentId column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:17:58 +02:00
5bed62dc72 feat(documents): document_folders schema + folder_id on documents
Adds a per-port folder tree (self-FK on parent_id, unlimited depth)
plus a nullable folder_id on documents (null = root). Sibling-name
uniqueness enforced via a unique index on (port_id, COALESCE(parent_id,
'__root__'), LOWER(name)) so two folders can't share a name inside
the same parent. ON DELETE SET NULL on documents.folder_id and ON
DELETE NO ACTION on the parent self-FK so a botched delete never
silently destroys data — the service layer implements soft-rescue
(bubble children up to parent) instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:12:44 +02:00
1bfed587b5 docs: website cutover runbook + post-execution status snapshot
Captures the agreed cutover plan (Q6 in the decisions log: double-write
transition window, ~30 days, then NocoDB decommission). The CRM side
is wired today — public berth feed, website-inquiries intake, dual-mode
health probe, WEBSITE_INTAKE_SECRET env var. The runbook documents the
website-repo checklist and rollback path so we can pick it back up
when prep for prod begins.

Refreshes the audit-followups status snapshot to reflect what shipped
this session. Wave 11 is now broken out into A-G subitems so the
remaining group-discussion work is enumerated rather than collapsed.

Note: .env.example separately needs WEBSITE_INTAKE_SECRET added (see
runbook §Endpoints). The husky pre-commit hook blocks .env* files
intentionally — pass via a separate workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:38:46 +02:00
72f50b681c feat(berths): split Documents tab into Spec + Deal Documents
Berth detail page now has two tabs:
  - Spec: the existing versioned berth-spec PDF surface (current panel,
    version history, parser badge).
  - Deal Documents: NEW. Lists EOIs / contracts / etc. attached to
    interests currently linked to this berth via interest_berths.

New service helper listDealDocumentsForBerth joins documents →
interests → interest_berths with a port_id guard on both sides.
GET /api/v1/berths/[id]/deal-documents wraps it, gated on berths.view.

Read-only — title, type, status badge, and an Open link to the source
interest page. Edits / sends still happen on the interest's own page.
The Spec tab paragraph now points reps to the new Deal Documents tab
instead of telling them to navigate via Interests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:37:16 +02:00
b93fdadb59 feat(berths): link prospect on status change + reason chips from vocabulary
When status moves to under_offer or sold, the dialog now surfaces an
interest selector below the reason textarea. Picking an interest
passes interestId on the PATCH, which the service uses to call
setPrimaryBerth — auto-creates a primary interest_berths row if not
present, demoting any prior primary in the same transaction so the
unique partial index never fires. Cross-port leakage is blocked inside
the existing interest-berths helper.

Reasons are now offered as quick-pick chips above the textarea,
sourced from the new berth_status_change_reasons vocabulary
(Wave 5). Clicking a chip fills the textarea so reps stay on the
keyboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:37:04 +02:00
da7ce16344 feat(admin): vocabularies page for per-port pick lists
New /admin/vocabularies route + VocabulariesManager component. Catalog
at src/lib/vocabularies.ts defines 11 vocabularies grouped into
Interests / Berths / Expenses / Documents domains, each shipping with
the canonical defaults from src/lib/constants.ts (interest temps,
status-change reasons, tenure types, expense categories, document
types, plus the 5 berth-spec dropdowns).

Editor supports add / remove / reorder / inline-rename / reset-to-
defaults; only dirty cards save. Uses the existing
/api/v1/admin/settings PUT endpoint (already gated on
admin.manage_settings) so storage piggybacks on system_settings
(port_id, key) per the established pattern.

Reps need read access without holding manage_settings — added a
public-read /api/v1/vocabularies endpoint plus useVocabulary() hook
(5-minute staleTime). The admin manager invalidates the vocabularies
query on save so consumers (status-change dialog, expense form, etc.)
pick up new lists immediately.

Adds a Vocabularies card to the admin landing page.

Follow-up sweep owed: actual consumers (interest-card temperature pill,
berth-tabs select dropdowns, expense form category list, etc.) still
read from the hardcoded constants.ts arrays. Wire them through
useVocabulary in a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:36:53 +02:00
07b5756014 feat(profile): first/last name fields + collapse notification preferences
Two related cleanups for the user profile surface area:

(1) Add canonical first_name + last_name columns to user_profiles.
    Migration 0049 backfills from display_name by splitting on the
    first whitespace run; single-token names land as
    (display_name, NULL) so we never throw away existing data.
    Display name becomes an optional override (nicknames, vanity
    formatting). /api/v1/me PATCH now accepts firstName/lastName,
    and the user-settings form surfaces them as the primary inputs
    with display name as a secondary "How your name appears" field.

(2) Remove the broken Notifications card from user-settings (it called
    PATCH on an endpoint that has GET/PUT only and used a flat shape
    vs the actual array shape). Replace with the working
    NotificationPreferencesForm + ReminderDigestForm under a
    #notifications anchor. /notifications/preferences becomes a
    server-side redirect to /settings#notifications for back-compat;
    the mobile More-sheet + user-menu Bell entry now deep-link to the
    new anchor directly.

Drops the auto-generated drizzle-kit catch-up migration so we're not
sneaking accumulated schema drift into the journal — only the targeted
0049 lands here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:36:31 +02:00
20ee2c1dcf feat(notes): aggregate-on-read for yachts, companies, residential clients
Extends the listForClientAggregated pattern to three new symmetric
helpers in notes.service so the Notes tab on yacht / company /
residential-client detail pages surfaces the full timeline (own notes
+ related-entity notes) instead of just rows on the entity itself.

  - listForYachtAggregated: yacht own + owner client (when ownership
    is polymorphic 'client') + linked interest notes.
  - listForCompanyAggregated: company own + company-owned yacht notes
    + interests linked to those yachts.
  - listForResidentialClientAggregated: own + residential interests.

Generalises NotesList so aggregate=true works for all four entity
types via SELF_SOURCE / AGGREGATABLE / SOURCE_BADGE_CLASS / SOURCE_LABEL
maps; cross-source notes render with a coloured chip and are read-only
(rep edits on the source entity's page so the right timeline records
the change).

Wires ?aggregate=true into the yacht / company / residential-client
notes routes; the yacht / company / residential-client tabs now pass
aggregate. Drops the legacy single-textarea spots on the companies
overview tab and the residential-interest "Initial brief" row in
favour of the threaded feed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:36:05 +02:00
43191659e6 feat(currency): sweep remaining concat call sites to formatCurrency
Builds on the centralised formatter shipped in ee2da8f. Replaces
\`\${currency} \${amount}\` style concatenations across the dashboard
revenue tooltip, command-search invoice/expense fallback labels,
expense-duplicate banner, and the invoice + expense PDF templates.
Drops the duplicate \`currencySymbol\` helper inside expense-pdf.service
in favour of the shared util; the two PDF helpers (renderReceiptHeader,
addReceiptErrorPage) now take a currency code instead of a pre-rendered
symbol so the formatter is the single source for spacing + thousands
separators. Also re-runs Prettier on the files where the prior commit
shipped without it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:35:34 +02:00
ee2da8f67e feat(currency): centralise money formatting + curated currency picker
New `src/lib/utils/currency.ts` is the single source of truth for
display formatting (`formatCurrency`) and the supported-currency
catalog (`SUPPORTED_CURRENCIES`, 10 codes covering the marina market).

New shared components:
- `<CurrencyInput>` — number input with leading symbol prefix and
  decimal inputMode, raw number value out via onChange.
- `<CurrencySelect>` — Select dropdown over `SUPPORTED_CURRENCIES`
  with symbol + code + label per row, replaces the free-text 3-letter
  inputs that let reps type "EURO" or "$$$" into a 3-char ISO column.

Threaded through every money input + display:
- Forms: berth (price/currency), expense (amount/currency), invoice
  (currency Select + line-items unit-price + step-3 review totals).
- Reads: berth-card / berth-columns / invoice-card / expense-card /
  dashboard KPIs / dashboard revenue-forecast / portal-invoices page.
  Each had its own ad-hoc `Intl.NumberFormat` wrapper with slightly
  different fallbacks; collapsed onto the shared helper.

`InvoiceLineItems` gained a `currency` prop so the unit-price input
prefix and the subtotal use the parent invoice's currency rather than
hard-coded `en-US` formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:24:46 +02:00
72ab7180cf feat(public-berths): expose booleans, metric variants, timestamps
Bring the public berth feed to verbatim NocoDB parity (all fields
except Price, which is held pending an explicit policy decision per
the audit follow-ups Q4). Adds:

- Berth Approved (boolean)
- Water Depth (number)
- Width Is Minimum / Water Depth Is Minimum (boolean)
- Length / Width / Draft / Water Depth / Nominal Boat Size (Metric)
- CreatedAt / UpdatedAt (ISO strings, useful for cache invalidation)

Booleans pass through as nullable to preserve NocoDB's tri-state
checkbox semantics (true / false / unset). Test fixtures cover the
new fields end-to-end including the null-passthrough case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:16:42 +02:00
91b5a41e10 fix(notes): add company_notes.updated_at, drop createdAt substitution
company_notes was missing updated_at — every other notes table has it,
and notes.service.ts substituted created_at into the response shape so
callers wouldn't notice. Add the column (defaulted + backfilled to
created_at for existing rows), wire the update path to set it on
edit, and drop the substitution from the read + edit handlers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:14:29 +02:00
e13232e2ad feat(berths): NocoDB-aligned dropdown enums + dual-unit auto-fill
Pull verbatim SingleSelect choices from NocoDB Berths via MCP and lock
them into BERTH_*_OPTIONS / _TYPES in lib/constants.ts: Side Pontoon
(10 values), Mooring Type (5), Cleat Type (2), Cleat Capacity (2),
Bollard Type (2), Bollard Capacity (2), Access (5), Area (A–E), Bow
Facing (4-value UX-only constraint over a SingleLineText). Power
Capacity / Voltage stay numeric inputs (NocoDB stores Number).

Add `toSelectOptions()` mapper for shadcn `<Select>` `{value,label}`
pairs.

Wire every berth dropdown — both the modal form and the inline-edit
detail tabs — to `<Select>`. Inline `EditableSpec` gains
`selectOptions` for the variant and `linkedUnit { field, multiplier }`
to auto-patch the metric column on save (× 0.3048 for ft→m on length,
width, draft, nominal boat size, water depth).

Promote nominal boat size + tenure type from read-only `<SpecRow>` to
`<EditableSpec>` so reps can edit them. Tenure type currently uses the
validator's `'permanent' | 'fixed_term'` set; will swap to per-port
configurable list once Vocabularies admin lands (Wave 5).

Mobile berth cards: replace status-coloured stripe with
`mooringLetterDot()` so it groups by dock letter; status conveyed by
the existing pill below. Berth detail header: "{Letter} Dock" chip
instead of bare "A" / "B" text. Berth area filter: `<Select>` over
A/B/C/D/E (renamed to "Dock"). Documents tab gets a one-paragraph
explainer disambiguating the spec PDF from deal documents (Interests
tab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:10:24 +02:00