Commit Graph

814 Commits

Author SHA1 Message Date
4dd1fa4b24 fix(documents): MoveToFolderDialog — Root search + reset on reopen
cmdk filters by the CommandItem value prop, so the sentinel
"__root__" silently failed to match natural search terms like "no
folder". Use the human label instead. Also reset pickedId when the
dialog re-opens so a cancelled pick doesn't carry a stale highlight
into the next open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:07:48 +02:00
e6103a4473 feat(documents): MoveToFolderDialog single-doc move picker
cmdk Combobox dialog showing all folder paths flat (' / '-separated),
plus a "Root (no folder)" pseudo-option. Move button disabled when the
picked folder matches the document's current folder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:04:24 +02:00
ebede74ca0 fix(documents): FolderActionsMenu — disable on pending + skip no-op rename
Pass loading={deleteMutation.isPending} to ConfirmationDialog so a
second tap on Delete doesn't dispatch a concurrent DELETE. Also
disable the rename Save button when the name hasn't changed, so an
accidental click doesn't fire a no-op PATCH and a misleading
"Folder renamed" toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:02:51 +02:00
bd8bb2e032 feat(documents): FolderActionsMenu (create / rename / delete dialogs)
DropdownMenu trigger with three actions: New folder (works at root or
inside the selected folder), Rename, Delete (confirm-then-soft-rescue).
Delete copy explicitly tells reps the contents move to the parent so
nothing dies silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:59:19 +02:00
d904122498 fix(documents): FolderBreadcrumb a11y — aria-hidden separators + aria-current
Match the existing src/components/ui/breadcrumb.tsx pattern: separator
chevrons are aria-hidden so screen readers don't announce them, and
the terminal segment (Root or current folder name) carries
aria-current="page" so SR users know which crumb is the current page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:57:30 +02:00
dd481e0c7d feat(documents): FolderBreadcrumb header crumb trail
Renders the current folder's path as a clickable breadcrumb with a
Home affordance back to "All documents". Each ancestor is clickable
to navigate up; the last segment is the current folder (non-clickable,
foreground colour).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:54:37 +02:00
1b441ca826 fix(documents): FolderTreeSidebar surfaces fetch error state
Folder query failures previously rendered identically to an empty
list, hiding network problems from the user. Add an isError branch
that shows "Failed to load folders." in destructive color.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:53:21 +02:00
104226f967 feat(documents): FolderTreeSidebar (collapsed-by-default tree)
Persistent left rail with "All documents" + "Root" pseudo-rows above
the tree. Each tree row has a chevron toggle (expand/collapse) and a
clickable label (select). Renders unlimited depth without blowing out
the page — children only mount when their parent is expanded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:49:26 +02:00
fb4b9c9595 feat(documents): useDocumentFolders hook + mutations
Wraps the folder tree fetch in TanStack with a 30s staleTime, and
provides create / rename / move / delete / move-document mutations
that invalidate the relevant query keys. buildFolderPaths flattens
the tree into ' / '-separated path strings for picker dropdowns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:43:29 +02:00
f286c4ef5f docs(plan): progress snapshot at Task 7 — backend complete, UI next
Tasks 1-7 done in subagent-driven mode (11 commits 5bed62da0ffa1b).
The entire DB + service + API layer for folders is shipped: schema,
manage_folders perm, listTree/createFolder/renameFolder/moveFolder/
deleteFolderSoftRescue, validators, all 4 folder routes, the per-doc
move endpoint, and the listDocuments folder filter (with descendant
expansion). Reps can already manage folders end-to-end via direct
API calls.

Records the design decisions made mid-execution: hybrid storage
strategy (UUID-flat + path-style download URLs), permission split,
soft-rescue delete semantics, cycle prevention with port-scoped
ancestor walk, PATCH-body exclusivity via .strict(), and the
updatedAt bump rule (per-doc move yes, bulk soft-rescue no).

Tests at pause: 1213/1213 vitest, tsc clean. Resume prompt + task
ordering for Task 8 onwards included so a fresh session can pick up
without context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:08:28 +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
e9d5df647d fix(documents): folder PATCH rejects bodies with both name and parentId
z.union picks the first member that parses successfully, so a body
with { name, parentId } would silently be parsed as a rename and the
parentId dropped. The route comment claimed this was rejected — it
wasn't. Adding .strict() to each branch makes the rejection real:
both members refuse extra keys, the union produces a 400, and the
rep gets feedback instead of a silent half-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:58:10 +02:00
1082b80542 feat(documents): folder CRUD API routes
GET /api/v1/document-folders → full tree (documents.view).
POST /api/v1/document-folders → create (documents.manage_folders).
PATCH /api/v1/document-folders/[id] → rename OR move (union schema —
refuses both in one body so audit logs stay one-op-per-call).
DELETE /api/v1/document-folders/[id] → soft-rescue delete; returns 204.

PATCH passes ctx.userId through to the service-level audit-log
emitters (renameFolder + moveFolder gained userId in Task 4 fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:55:39 +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
9f3e739c76 docs(plan): add Tasks 18-19 (path-style URLs + organized-bucket importer)
User chose the hybrid storage strategy after reviewing the cost
analysis: storage paths stay UUID-flat (preserves the established
pattern across brochures, berth PDFs, invoices, reports, templates,
expense receipts, and the migrate-storage byte-verbatim copy), but
documents gain a path-style download URL so reps see meaningful
paths in shared links and browser tabs.

Task 18 wires the new /api/v1/documents/[id]/download/[...slug]
catch-all route + a downloadUrl field on list/detail responses.
The slug is validated for truth so a hand-edited URL with a
stale path 404s instead of silently serving the wrong file.

Task 19 is the importer the user mentioned: a one-shot script
that walks an organized legacy bucket, creates matching folder
tree + document rows pointing at existing storage keys verbatim.
Idempotent via the sibling-uniqueness index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:50:28 +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
51a60c1b9e docs(plan): documents folders implementation plan
17-task TDD plan covering schema + migration, service (listTree,
create, rename, move with cycle prevention, soft-rescue delete),
validators, API routes, hook, sidebar tree, breadcrumb, actions menu,
move-to-folder dialog, hub wiring (drop signature-only pill, In-
progress tab, dynamic type chips), admin-configurable Expired tab,
Playwright smoke, and CLAUDE.md update. Decisions locked: port-wide
tree, hub tabs stay flat, documents.manage_folders new perm, soft-
rescue on delete (never CASCADE), folder watchers out of scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:59:30 +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
7c25d1aef6 feat(expenses): combobox trip-label picker (free text + past suggestions)
Replaces the HTML5 datalist Input with a Popover + cmdk Combobox
modeled after CountryCombobox. Free-text on first entry via the
"Create '<typed value>'" item; past labels grouped under "Past trips"
with a check-mark indicating the current selection. Reuses the
existing /api/v1/expenses/trip-labels endpoint (distinct values for
the port, ordered by most-recent expense date) — no new schema or
service work.

Drops useQuery from expense-form-dialog since the combobox now owns
its own data fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:36:16 +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
7804e9bb17 docs(audit-followups): record 11 decisions from 2026-05-09 review
Replace the open-questions section with a Decisions log capturing the
chosen direction for vocabularies admin, notification prefs, name
fields, public-feed parity, website cutover, status-change prospect
link, trip-label UX, documents folders, and the berth Documents tab
split. Quick-status snapshot updated to reflect that Waves 4-10 are
now ready to start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:34:59 +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
8fdf7a92cf docs(claude-md): correct /api/public/health response shape note
The route is dual-mode — anonymous probes get a minimal `{status,
timestamp}` (so uptime monitors that can't carry the secret keep
working and never 503 on the platform), authenticated probes
(timing-safe X-Intake-Secret match against WEBSITE_INTAKE_SECRET) get
the full `{status, env, appUrl, timestamp, checks}` envelope and a
503 on hard dependency failures. Old doc only described the second
shape and didn't mention the secret gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:15:07 +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
502455ac04 chore(format): apply prettier auto-formatting
Pre-commit hook reformatted these files after the substantive commits.
No semantic changes — markdown table alignment, list indentation, and
emphasis style normalisation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:11:54 +02:00
aad514a3bd docs(audit): 2026-05-08 mobile audit follow-ups index
Single source of truth for the 2026-05-08 visual-audit work. Owns
status of each item, file pointers, every open question, and a
ready-to-paste prompt for resuming in a fresh session. Items grouped
by wave (the original triage buckets, kept stable across sessions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:11:24 +02:00
3f86baeb0f chore(ui): drop unused dashboard KPIs + soften membership wording
Remove the "Total Clients / Active Interests / Pipeline Value /
Occupancy Rate" KPI grid from the dashboard — duplicated by the
charts below and rarely scanned. Pipeline funnel, occupancy timeline,
revenue breakdown, lead source charts and the activity feed remain.

Rename the company-members dropdown action "End Membership" →
"Remove from company" — matches how reps describe the action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:11:15 +02:00
19622985b5 fix(layout): mobile UX cleanup + interest-stage legend popover
Mobile UX:
- Hide ColumnPicker on `< sm` viewports (cards, no columns to toggle).
- Hide kanban toggle in interest list on mobile and snap viewMode back
  to 'table' if the persisted choice was 'board'.
- Drop dead "Inbox" link from the More-sheet (email/IMAP feature is
  deferred per sidebar.tsx note).
- Repoint Notifications nav from `/notifications` (no page.tsx — 404)
  to `/notifications/preferences` and re-label as "Notification
  preferences" (the bell stays the surface for actual notifications).
- Hide Website Analytics on both desktop sidebar and mobile More-sheet
  when Umami isn't configured for the port (`useUmamiActive()`).

Interests:
- New `<StageLegend>` popover button in the filter row decodes the
  card stripe colours to pipeline stage names, kept in sync with
  `STAGE_DOT` automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:11:01 +02:00
82fd75081a feat(forms): country→timezone autoset, "Other" channel hint, polish
Client form: when nationality is picked and timezone empty, primary
IANA zone for the country is pre-filled (skips when user has chosen
a zone explicitly). When a contact's preferred channel is `'other'`,
the inline `Label` field flips to "Specify" / "e.g. Telegram, Signal"
so the rep records what the channel actually is.

Yacht form: replace the free-text 2-letter flag input with the shared
`CountryCombobox` so flags stay valid ISO codes.

User settings: timezone pre-populates from
`Intl.DateTimeFormat().resolvedOptions().timeZone` on first load
(was empty before); country change auto-fills timezone with the same
helper as the client form. Phone field upgraded to the shared
`<PhoneInput>` (country-flag dropdown + AsYouType formatter) seeded
from the page's country state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:10:47 +02:00
3c47f6b7f9 fix(ui): cmdk wheel-scroll on macOS + match dropdown widths to trigger
Radix Popover swallowed wheel events on cmdk-backed comboboxes for
macOS users — the country / timezone dropdowns were unscrollable with
a Magic Mouse / trackpad. Add an `onWheel` translator on `CommandList`
plus `overscroll-contain` so the list captures the delta directly.
Lights up every cmdk popover (Companies, Residential Clients, Clients,
Yachts, settings).

Country and Timezone comboboxes now constrain popover content to
`w-[var(--radix-popper-anchor-width)]` with sensible `min-w-*` floors
so wide triggers get correspondingly wide popovers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:10:35 +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
4d6a293534 fix(berths): natural-sort SQL ordering for mooring numbers
Drizzle SQL template was running `\d+$` through JS string-literal
escape rules, eating the backslash and matching every character class
\d alias instead of digits. Berths sorted lexically (A1, A10, A11,
A2, …) instead of by trailing number. Switch to `[0-9]+$` POSIX form
which survives the escape pass intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:10:04 +02:00
9b4aabe04b chore(dev): enable Turbopack and lift typedRoutes out of experimental
`pnpm dev` now runs `next dev --turbopack` (10–20× speedup vs webpack
on cold compile and HMR). Promote `typedRoutes` out of `experimental`
to match Next 15.5's stable surface; auto-update `next-env.d.ts` to
reference the generated routes.d.ts. Ignore that file in eslint since
Next regenerates it and the triple-slash style is fixed by the
framework.

`next.config.ts` has no custom `webpack()` hook so reverting to the
plain dev server is one line if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:09:56 +02:00
e01a87ff2e fix(auth): forward in checks through better-auth Proxy
better-auth's `toNextJsHandler` does `"handler" in auth` and falls back
to calling `auth(req)` if false. The default `has` trap looks at the
empty target and returns false, so without the override we hit the
fallback and crash because the target isn't callable. Add a `has` trap
that delegates to the real instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:09:17 +02:00
1a2d2dd1e1 chore(deps): pnpm overrides for vite/esbuild/postcss (close transitive CVEs)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 1m28s
Build & Push Docker Images / build-and-push (push) Successful in 8m36s
Brings pnpm audit to zero (was 47 going in this session).

These three couldn't be cleanly bumped at the top level because they're
transitive deps of dev tools we can't touch yet:
- vite@8.0.0 came in via vitest@4.1.5 (which is the latest vitest);
  fixes Vite ".../fs.deny" bypass + arbitrary file read via dev-server
  WebSocket (both high).
- Older esbuild dupes came via tsx, drizzle-kit, vite, etc.; fixes
  esbuild dev-server CORS-bypass advisory.
- Older postcss dupes came via postcss-import / postcss-js / postcss-nested
  / postcss-load-config (all transitive of tailwindcss 3); fixes the
  unescaped </style> XSS in stringify output.

`pnpm.overrides` syntax in package.json forces the version everywhere.
Used an exact pin for vite (it's strict-pinned by vitest) and >= ranges
for the other two.

Also rolled esbuild dev dep back to 0.27.7 to satisfy vitest's peer
dep (vitest expects ^0.27.0; we'd briefly bumped to 0.28.0).

Tests: 1185/1185. pnpm audit: 0 vulnerabilities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:16:27 +02:00
020aabcb4e chore(deps): typescript 5→6, @types/node 22→25, esbuild 0.25→0.28
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
All three were drop-in within the major-version range; the only
required code change was adding `src/types/css.d.ts` to declare the
`*.css` side-effect import shape (TypeScript 6 stopped silently
accepting unknown side-effect imports).

Tests: 1185/1185 vitest. tsc clean. build:worker clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:10:09 +02:00
2b1024ff7a fix(types): unblock catch-all routes under stricter Next 15.5 typing + Phase 2B deps
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m26s
Build & Push Docker Images / build-and-push (push) Has been cancelled
Two changes bundled (build was failing on the type fix; deps came along
on the same branch).

1. RouteHandler / withAuth / withPermission are now generic over the
   route's params shape. Default stays `Record<string, string>` for the
   common `[id]`-style routes (no caller changes needed). Catch-all
   routes like `[...path]` declare their narrow shape via a type-arg:

       export const PATCH = withAuth<{ path: string[] }>(
         withPermission<{ path: string[] }>('files', 'manage_folders',
           async (req, ctx, params) => { /* params.path: string[] */ }
         ),
       );

   Without this, Next.js 15.5+'s stricter route-type checking rejected
   the build because the inferred `params: Promise<{ path: string[] }>`
   for `[...path]` doesn't satisfy `Promise<Record<string, string>>`.

   Updated `src/app/api/v1/files/folders/[...path]/route.ts` (the only
   catch-all in the tree right now) to use the new generic.

2. Phase 2B deps (within-major-jump where the API didn't actually break):
   - @pdfme/common, @pdfme/generator, @pdfme/schemas: 5.5.10 → 6.1.2
     (closes 3 mod XSS/SSRF/decompression-bomb advisories)
   - lucide-react: 0.460.0 → 1.14.0
   - sonner: 1.7.4 → 2.0.7
   - tailwind-merge: 2.6.1 → 3.5.0

Tests: 1185/1185 vitest. tsc clean. Local `next build` succeeds.

Reverted (deferred to a focused PR):
- @hookform/resolvers 5: Resolver<T> typing change requires per-form
  useForm migration
- eslint 10: incompatible with @rushstack/eslint-patch (pulled in by
  eslint-config-next)
- react-day-picker 10: ClassNames removed `table`; needs calendar.tsx
  migration
- zod 4: 94 type errors cascading through drizzle insert types; needs
  comprehensive migration

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:07:07 +02:00
fdb5beb81a chore(deps): Phase 2 majors — nodemailer, archiver, pino, lint-staged
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m33s
Build & Push Docker Images / build-and-push (push) Failing after 4m12s
Bumped within-major-jump deps where API surface didn't change:
- nodemailer 6.10.1 → 8.0.7 (closes 1 high DoS in addressparser, 1 mod
  SMTP injection via CRLF in EHLO/HELO transport name, 1 low SMTP
  injection via envelope.size)
- @types/nodemailer 6.4.23 → 8.0.0 (matches runtime)
- archiver 7.0.1 → 8.0.0 (zip lib; no API breaks for our usage)
- pino 9.14.0 → 10.3.1 (logger; same API)
- eslint-config-prettier 9.1.2 → 10.1.8 (eslint v9 compat)
- lint-staged 15.5.2 → 17.0.3 (no config changes needed)

Skipped this batch (defer to a focused PR):
- @hookform/resolvers 3 → 5: changed Resolver<T> typing, broke
  type-checks in interest-form, yacht-form, expense-form. Needs a
  per-form migration to align useForm generics. Reverted to 3.10.0.

Tests: 1185/1185 vitest passing. Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:37:55 +02:00