Commit Graph

265 Commits

Author SHA1 Message Date
Matt Ciaccio
6eb0d3dc92 docs(ops): backup/restore + email deliverability runbooks
Two new runbooks under docs/runbooks/ plus the automation scripts the
backup runbook references. Both are written so an operator who has only
the off-site backup credentials and the runbook can recover the system
unaided.

Backup/restore (Phase 4a):
- docs/runbooks/backup-and-restore.md — covers what gets backed up
  (Postgres / MinIO / .env+ENCRYPTION_KEY), schedule (hourly DB +
  hourly MinIO mirror, 7-day hourly + 30-day daily retention),
  cold-restore procedure with row-count verification, weekly drill
- scripts/backup/pg-backup.sh — pg_dump → gzip → optional GPG → mc
  upload, fails loud
- scripts/backup/minio-mirror.sh — incremental mc mirror, no --remove
  flag so accidental deletes on the live bucket can't cascade
- scripts/backup/restore.sh — interactive prod restore + --drill mode
  that runs against a sandbox DB and diffs row counts

Email deliverability (Phase 4b):
- docs/runbooks/email-deliverability.md — what the CRM sends, DNS
  records (SPF/DKIM/DMARC/MX), per-port override implications,
  diagnosis flow ("didn't arrive" → 4-step checklist starting with
  EMAIL_REDIRECT_TO), provider migration plan, realapi suite as the
  end-to-end probe

Tests still 778/778 vitest, tsc/lint clean — these phases are docs +
shell scripts, no code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:10:30 +02:00
Matt Ciaccio
a3305a94f3 feat(gdpr): staff-triggered client-data export bundle (Article 15)
Adds a full GDPR Article 15 (right of access) workflow. Staff trigger
an export from the client detail; a BullMQ worker assembles every row
keyed to that client (profile, contacts, addresses, notes, tags,
yachts, company memberships, interests, reservations, invoices,
documents, last 500 audit events) into JSON + a self-contained HTML
report, ZIPs them, uploads to MinIO, and optionally emails the client
a 7-day signed download link.

- New table gdpr_exports tracks lifecycle (pending → building → ready
  → sent / failed) with a 30-day cleanup target
- Bundle builder (gdpr-bundle-builder.ts) — pure read-side, tenant-
  scoped, with HTML escaping to block injection from rogue field values
- Worker hook in export queue dispatches on job name 'gdpr-export'
- New audit actions: 'request_gdpr_export', 'send_gdpr_export'
- API: POST/GET /api/v1/clients/:id/gdpr-export (admin-gated, exports
  rate-limit, Article-15 audit on POST); GET /:exportId returns a
  fresh signed URL
- UI: <GdprExportButton> dialog on client detail header — admin-only,
  shows recent exports, supports email-to-client + override recipient,
  polls every 5s while open
- Validation: refuses email-to-client when no primary email + no
  override (rather than silently dropping the send)

Tests: 778/778 vitest (was 771) — +7 covering builder happy path,
HTML escaping, tenant isolation, empty client, request-flow validation,
and audit / queue interaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:06:31 +02:00
Matt Ciaccio
9dfa04094b feat(rate-limit): per-user limiters for OCR, AI, and exports
Adds three named rate limiters to the existing Redis sliding-window
catalog and a withRateLimit wrapper that composes inside withAuth.
Wires the OCR limiter into the receipt-scan endpoint so a runaway
client can't burn through the AI budget in a tight loop.

- ocr: 10/min/user
- ai: 60/min/user (reserved for future server-side AI surfaces)
- exports: 30/hour/user (reserved for GDPR bundle, PDF, CSV exports)

429 responses include X-RateLimit-* headers and a Retry-After hint.

Tests: 771/771 vitest (was 766) — +5 rate-limit tests covering catalog
shape, sliding window, cross-prefix isolation, cross-user isolation,
and resetAt timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:56:01 +02:00
Matt Ciaccio
e7d23b254c feat(ai): per-port token budgets + usage ledger for AI features
Adds a token-denominated guardrail in front of every server-side AI call
so a misconfigured port can't run up an unbounded bill. Soft caps surface
a banner; hard caps refuse new requests until the period rolls over.
Usage flows into a feature-typed ledger so future AI surfaces (summary,
embeddings, reply-draft) can drop in without schema changes.

- New table ai_usage_ledger (port, user, feature, provider, model,
  input/output/total tokens, request id) with two indexes for rollup
- New service ai-budget.service.ts: getAiBudget/setAiBudget,
  checkBudget (pre-flight gate), recordAiUsage, currentPeriodTokens,
  periodBreakdown — all token-based, period boundaries in UTC
- runOcr now returns provider usage so the route can record the actual
  spend instead of estimating
- Scan-receipt route gates on checkBudget before invoking AI; returns
  source: manual / reason: budget-exceeded when blocked, surfaces
  softCapWarning on the success path
- Admin UI: new AiBudgetCard on the OCR settings page — shows current
  spend, per-feature breakdown, soft/hard cap inputs, period selector
- Permission: admin.manage_settings on both routes

Tests: 766/766 vitest (was 756) — +10 budget tests covering enforce/
disabled/cap-exceed/estimate-exceed/soft-warn/period boundaries/
cross-port isolation/silent ledger failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:53:09 +02:00
Matt Ciaccio
2cf1bd9754 feat(ocr): Tesseract.js as default scanner, AI as opt-in per port
The mobile receipt scanner now runs Tesseract.js in-browser by default —
on-device, free, and image bytes never leave the device. AI providers
(OpenAI / Claude) become a per-port opt-in for higher accuracy on
hard-to-read receipts.

- Lazy-load Tesseract WASM in src/lib/ocr/tesseract-client.ts (5 MB
  bundle dynamic-imports on first scan, not in main chunk)
- Heuristic parser src/lib/ocr/parse-receipt-text.ts extracts vendor,
  date, amount, currency, and line items from raw OCR text
- New port-scoped aiEnabled flag on OcrConfig (defaults false). Resolved
  flag never inherits from the global row — each port admin opts in
  independently
- Scan endpoint short-circuits to manual-mode when aiEnabled=false so
  the AI provider is never invoked unless the admin has flipped the
  switch
- Scan UI runs Tesseract first, then asks the server whether AI is
  enabled — uses the AI result only when its confidence beats Tesseract;
  network failures degrade gracefully to the local parse
- Admin OCR-settings form gains the per-port aiEnabled checkbox

Tests: 756/756 vitest (was 747) — +7 parser unit tests, +2 aiEnabled
config tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:46:29 +02:00
Matt Ciaccio
46937bbcb9 feat(addresses): full CRUD UI for client + company multi-address
Client and company detail pages each gain an Addresses tab with click-to-edit
fields wired to the existing CountryCombobox/SubdivisionCombobox primitives.
Adds a primary toggle that demotes the previous primary inside one transaction
so the partial unique index never trips.

- New service helpers: list/add/update/remove ClientAddress + CompanyAddress
- New routes: /api/v1/clients/[id]/addresses[/addressId], same under companies/
- New shared component: <AddressesEditor> reused by both detail surfaces
- Integration tests cover happy path, primary demotion, and tenant scoping

Tests: 747/747 vitest (was 741, +6 address tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:38:43 +02:00
Matt Ciaccio
27cdbcc695 chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).

Migration 0016 drops:
  - clients.nationality
  - companies.incorporation_country
  - client_addresses.{state_province, country}
  - company_addresses.{state_province, country}

Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.

Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.

Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.

Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').

Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00
Matt Ciaccio
31fa3d08ec chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms
Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.

PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
     phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
     and company.{incorporationCountryIso, incorporationSubdivisionIso}.
     Server-side parsePhone() fallback for legacy raw phone strings.

PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
     'audit.suspicious_login') were registered but evaluators returned [].
     Both required schema/instrumentation that hadn't landed. Removed from
     the registry; comments record the dependencies needed to revive them.
     Effective rule count: 8 active.

PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
     integration test files; webhook-delivery uses vi.hoisted for the
     queue-add ref. Vitest no longer warns about non-top-level mocks.
     Deflaked the 'short value' assertion in security-encryption.test.ts
     by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.

PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
     now filter by isNull(archivedAt). Berths use status (no archivedAt).

PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
     walks every src/app/api/v1/**/route.ts and reports handlers without a
     withPermission() wrapper. Initial run found 33 violations.
     - Allow-listed 17 with explicit reasons (self-data, admin, alerts,
       search, currency, ai, custom-fields — some marked TODO).
     - Wrapped 7 routes with concrete permissions: clients/options
       (clients:view), berths/options (berths:view), dashboard/*
       (reports:view_dashboard), analytics (reports:view_analytics).
     Audit report at docs/runbooks/permission-audit.md. Script exits
     non-zero on any unallow-listed violation so it can become a CI gate.

Vitest: 741 -> 741 (no new tests; existing suite covers the changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:48:22 +02:00
Matt Ciaccio
16d98d630e feat(i18n): country/phone/timezone/subdivision primitives + form wiring
Cross-cutting i18n polish for forms across the marina + residential + company
domains. Introduces a single source of truth for country/phone/timezone/
subdivision data and replaces every nationality-as-free-text and timezone-
as-string Input with a dedicated combobox.

PR1  Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames
     for localized labels, detectDefaultCountry() with navigator-region
     fallback to US, CountryCombobox with regional-indicator flag glyphs +
     compact mode for inline use.
PR2  Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType /
     callingCodeFor), PhoneInput with flag dropdown + national-format
     AsYouType + paste-detect that flips the country dropdown for pasted
     international strings.
PR3  Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/
     ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"),
     TimezoneCombobox with Suggested/All grouping driven by countryHint.
PR4  Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2
     codes for every country), per-country cache, SubdivisionCombobox with
     "Pick a country first" / "No regions available" empty states.
PR5  Schema deltas (migration 0015) — clients.nationality_iso, clientContacts
     {value_e164, value_country}, clientAddresses {country_iso, subdivision_iso},
     residentialClients {phone_e164, phone_country, nationality_iso, timezone,
     place_of_residence_country_iso, subdivision_iso}, companies {incorporation_
     country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso,
     subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used
     by every entity validator + route handler.
PR6  ClientForm + ClientDetail — CountryCombobox replaces nationality Input,
     TimezoneCombobox replaces timezone Input (driven by nationalityIso hint),
     PhoneInput conditionally rendered for phone/whatsapp contacts. Inline
     editors (InlineCountryField / InlineTimezoneField / InlinePhoneField)
     for the detail-page overview rows + ContactsEditor.
PR7  Residential client form + detail — phone -> PhoneInput, nationality/
     timezone/place-of-residence-country/subdivision rows in both create
     sheet and inline-editable detail view. Subdivision wipes when country
     flips since codes are country-scoped.
PR8  Company form + detail — incorporation country -> CountryCombobox,
     incorporation region -> SubdivisionCombobox in both modes.
PR9  Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry
     and i18n fields from newer website builds, server-side parsePhone()
     fallback for legacy raw-international submissions. Old Nuxt builds
     keep working unchanged.

Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for
the public phone-normalization path (3 tests), 1 smoke spec asserting the
combobox triggers render in all three create sheets.

Test totals: vitest 713 -> 741 (+28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:13:08 +02:00
Matt Ciaccio
f52d21df83 feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.

PR4  Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
     date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5  Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
     right-rail, three-tab page (active/dismissed/resolved), socket-driven
     invalidation. Bell lazy-loads list on popover open to keep cold pages
     fast in non-dashboard routes.
PR6  EOI queue tab on documents hub — filters to in-flight EOIs, count
     surfaces in tab label.
PR7  Interests-by-berth tab on berth detail — replaces the stub.
PR8  Expense duplicate detection — BullMQ job runs scan on create, yellow
     banner on detail w/ Merge / Not-a-duplicate, transactional merge
     consolidates receipts and archives the source.
PR9  Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
     its own (scanner) group with no dashboard chrome, dynamic per-port
     manifest, OpenAI + Claude provider abstraction, admin OCR settings
     page (port-level + super-admin global default w/ opt-in fallback),
     test-connection endpoint, manual-entry fallback when no key is
     configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
     existing GIN index, cursor pagination, filters for entity/action/user
     /date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
     real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
     socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
     cleanly without their gate envs so CI stays green.

Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
Matt Ciaccio
2fa70f4582 merge: PR3 — analytics snapshot service + refresh job (Phase B)
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m1s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:54:48 +02:00
Matt Ciaccio
01b201e1a2 feat(analytics): real computations + 15-min snapshot refresh job
PR3 of Phase B. Replaces the no-op stubs in analytics.service.ts with
working drizzle queries and adds the recurring BullMQ job that warms
the cache.

Computations:
- computePipelineFunnel: groups interests by pipeline_stage filtered by
  port + range + not archived; emits 8-row stages array with conversion
  pct relative to 'open' as the funnel top.
- computeOccupancyTimeline: per day in range, counts berths covered by
  an active reservation (start_date ≤ day, end_date IS NULL OR ≥ day);
  emits {date, occupied, total, occupancyPct}.
- computeRevenueBreakdown: sums invoices.total grouped by status +
  currency; filters out archived rows.
- computeLeadSourceAttribution: counts interests by source descending;
  null source bucketed as 'unspecified'.

Public API (getPipelineFunnel, getOccupancyTimeline, etc.) reads
analytics_snapshots first; falls back to compute + writeSnapshot. TTL
15 minutes (matches the cron interval).

Cron:
- queue/scheduler.ts registers 'analytics-refresh' on maintenance with
  pattern '*/15 * * * *'.
- queue/workers/maintenance.ts dispatches to refreshSnapshotsForPort
  for every port; per-port try/catch so one bad port doesn't kill the
  sweep.

Tests: tests/integration/analytics-service.test.ts (9 cases). Pipeline
funnel math (incl. zero state), occupancy timeline shape/percentages
with seeded reservations, revenue grouped by status + currency, lead
source attribution incl. null bucketing, cache hit (mutate snapshot
directly → next read returns mutated value), refreshSnapshotsForPort
warms every metric×range combo.

Vitest 690/690 (+9). tsc + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:54:46 +02:00
Matt Ciaccio
94f049c8b8 merge: PR2 — alert rules engine + cron + socket (Phase B)
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m3s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:57 +02:00
Matt Ciaccio
df495133b7 feat(alerts): rule engine, recurring evaluator, socket fanout
PR2 of Phase B. Wires the alert framework end-to-end:

- alert-rules.ts: 10 rule evaluators implemented as pure async fns over
  the existing schema. reservation.no_agreement, interest.stale,
  document.signer_overdue, berth.under_offer_stalled, expense.duplicate,
  expense.unscanned, interest.high_value_silent, eoi.unsigned_long,
  audit.suspicious_login fire against real conditions.
  document.expiring_soon stays inert until the documents schema gets an
  expires_at column. audit.suspicious_login also stays inert until the
  auth layer logs 'login.failed' rows (TODO noted in the rule body).

- alert-engine.ts: runAlertEngine() walks every port × every rule and
  calls reconcileAlertsForPort. Errors per (port, rule) are collected
  in the summary, not thrown — one bad evaluator can't stop the sweep.

- alerts.service.ts: reconcileAlertsForPort now emits 'alert:created'
  socket events on insert and 'alert:resolved' on auto-resolve;
  dismissAlert emits 'alert:dismissed'. All scoped to port:{portId}
  rooms.

- socket/events.ts: adds the three Server→Client alert event types.

- queue/scheduler.ts: registers 'alerts-evaluate' on the maintenance
  queue with cron */5 * * * * (every 5 min, per spec risk register).

- queue/workers/maintenance.ts: dispatches 'alerts-evaluate' to
  runAlertEngine; logs sweep summary.

Tests:
- tests/integration/alerts-engine.test.ts (6 cases): seeds reservation
  → fires, runs twice → no dupe, adds agreement → auto-resolves; seeds
  stale interest → fires; hot lead silent → critical; engine summary
  shape on no-data port. Socket emit module is vi.mocked.

Vitest 681/681 (was 675; +6). tsc clean. Lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:50:55 +02:00
Matt Ciaccio
639025ebf9 merge: PR1 — Phase B schema + service skeletons (Phase B)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:43:03 +02:00
Matt Ciaccio
e77d55ac50 feat(insights): Phase B schema + service skeletons
PR1 of Phase B per docs/superpowers/specs/2026-04-28-phase-b-insights-alerts-design.md.
Lays the foundation that PRs 2-10 will fill in with behaviour.

Schema (migration 0014):
- alerts table with rule-engine fields (rule_id, severity, link,
  entity_type/id, fingerprint, fired/dismissed/acknowledged/resolved
  timestamps, jsonb metadata). Partial-unique fingerprint index keeps
  one open row per (port, rule, entity); separate indexes power
  severity-filtered and time-ordered queries.
- analytics_snapshots (port_id, metric_id) -> jsonb cache + computedAt
  for the 15-min recurring refresh.
- expenses: duplicate_of self-FK, dedup_scanned_at, ocr_status/raw/
  confidence; partial index on (port, vendor, amount, date) where
  duplicate_of IS NULL drives the dedup heuristic.
- audit_logs.search_text: GENERATED ALWAYS tsvector over
  action+entity_type+entity_id+user_id, GIN-indexed (drizzle can't
  model GENERATED ALWAYS in TS yet, so the migration appends manual
  ALTER + the GIN index).

Service skeletons in src/lib/services/:
- alerts.service.ts: fingerprintFor, reconcileAlertsForPort (upsert +
  auto-resolve), dismiss, acknowledge, listAlertsForPort.
- alert-rules.ts: RULE_REGISTRY of 10 rule evaluators (currently no-op);
  PR2 fills in the bodies.
- analytics.service.ts: readSnapshot/writeSnapshot with 15-min TTL +
  no-op compute* stubs for the four chart series; PR3 fills behavior.
- expense-dedup.service.ts: scanForDuplicates + markBestDuplicate
  using the partial dedup index. PR8 wires the BullMQ trigger.
- expense-ocr.service.ts: OcrResult/OcrLineItem types + ocrReceipt
  stub. PR9 wires Claude Vision (Haiku 4.5 + ephemeral system-prompt
  cache).
- audit-search.service.ts: tsvector @@ plainto_tsquery + cursor
  pagination on (createdAt, id). PR10 wires the admin UI.

tsc clean, lint clean, vitest 675/675 (one unrelated AES random-output
flake passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:43:01 +02:00
Matt Ciaccio
f1ed2a5f87 docs(spec): Phase B — insights, alerts, and operational awareness
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m4s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Full execution plan for the next phase. Closes the seven priority gaps
the 2026-04-28 Nuxt→Next audit surfaced (analytics, alerts,
interests-by-berth, expense dedup, EOI queue, OCR, audit log read view).

Scope:
- Analytics dashboard with KPI tiles, pipeline funnel, occupancy
  timeline, revenue breakdown, lead-source attribution; cached via
  `analytics_snapshots` recurring job.
- Alert framework: 10-rule v1 catalog, rule engine evaluates on cron,
  fingerprint dedupes, auto-resolves when condition clears, surfaces
  in dashboard right rail + dedicated /alerts page.
- Interests-by-berth tab on berth detail.
- Expense duplicate detection (vendor + amount + date ±3d) with
  merge action.
- OCR for expense receipts via Claude Vision (Haiku 4.5 + ephemeral
  system-prompt cache).
- Audit log admin read view with tsvector search + cursor pagination.
- EOI queue: saved-view tab on the documents hub.

11 PRs, ~10-13 dev days, calendar 2.5-3 weeks. Critical path
graphed. Risk register includes alert false-positive mitigation,
OCR cost ceiling via Haiku + cache, and audit-log scale.

Four open questions for the user in the spec footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:00:01 +02:00
Matt Ciaccio
4036c16f39 test(infra): vitest globalSetup teardown purges test-port-* leaks
Integration tests use makePort() which writes ports with slug 'test-port-{rand}'
and never cleans up. Result: 17,564 leaked rows in dev that slowed every page
load fetching the port-switcher list (and was contributing to smoke flakes).

Adds tests/global-setup.ts with a teardown() that DELETEs every 'test-port-%'
row plus its dependent rows across 30+ tables in one CTE. Wires it into
vitest.config.ts via globalSetup. Adds closeDb() helper so the teardown can
end the postgres-js pool cleanly (kills the 'Tests closed but Vite server
won't exit' warning).

Also lands docs/superpowers/specs/2026-04-28-country-phone-timezone-design.md
— full-scope agenda for the country dropdown / E.164 phone input /
country-driven timezone autofill work, ~7 dev days across 10 PRs. Per
user request: 'let's do this full-fledged if we're gonna do it'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:28:15 +02:00
Matt Ciaccio
5f9bbb97bd fix(sidebar): replace floating circular collapse button with blended row
User feedback: the circular toggle floating off the sidebar's right
edge looked tacked-on. Replaced with a flush full-width row above the
user footer (right-aligned 'Collapse <' chip when expanded; centered
chevron when collapsed). Same nav-item hover treatment so it merges
visually with the sidebar palette. The <aside> no longer needs to
host an overhanging button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:00:20 +02:00
Matt Ciaccio
4911083d0f fix(visual): KPITile data-testid + restore residential interest casing
Post-PR10c follow-ups discovered during smoke triage:
- KPITile gets data-testid="kpi-tile" so the dashboard smoke spec's
  '[data-testid*="kpi"]' selector matches (test 10-dashboard:27 expected
  >=4 kpi cards; the old Card-based render was matched by the
  '[class*="card"]' branch and didn't need a testid).
- Residential interest detail eyebrow text reverted from "Residential
  Interest" to "Residential interest" (lowercase i). The visual is
  identical because the wrapper has the `uppercase` class; the smoke
  spec at 26-residential:140 looks for the literal lowercase string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:56:32 +02:00
Matt Ciaccio
3a7fef59b0 fix(visual): dark-mode-safe borders + sidebar relative + ring-background
Code-review follow-up to PR10b-e:
- DetailHeaderStrip + KPITile: border-slate-200 → border-border so dark
  mode doesn't paint a bright halo around the gradient strip.
- Topbar avatar: ring-white → ring-background so the 2px ring tracks
  the surface (matches the sidebar footer pattern).
- KpiTileSkeleton stripe: bg-slate-100 → bg-muted for parity with
  shadcn skeleton tokens in dark mode.
- Sidebar <aside>: add `relative` so the absolute-positioned
  collapse-toggle button anchors to the sidebar itself rather than
  the nearest positioned ancestor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:24:14 +02:00
Matt Ciaccio
c081334020 merge: PR10e — visual polish (mobile responsive sweep) (Phase A)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:22 +02:00
Matt Ciaccio
2d1b50745a style(mobile): responsive tabs + table overflow + hub flex-wrap (Phase A)
Adds <ResponsiveTabs> primitive that swaps the TabsList for a native Select on
phone-sized viewports (<640px). DetailLayout now routes its tab strip through it,
so every tabbed detail page gets the collapse for free. DataTable wraps the
Table in overflow-x-auto so wide column sets scroll horizontally instead of
breaking the layout under 768px. Documents-hub row swaps the fixed
grid-cols-[auto_1fr_auto_auto_auto_auto] for flex-wrap below sm: so signers /
status / dates stack instead of clipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:21 +02:00
Matt Ciaccio
40ae860a88 merge: PR10d — visual polish (sidebar/topbar) (Phase A)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:12 +02:00
Matt Ciaccio
c7ca7c1f96 style(layout): sidebar stripe + topbar gradient + bell spring + search ring
Sidebar active items: 4px brand left-edge stripe (rounded-r-full) replacing the
border-l-2 + bg shift; section header smaller-caps + brand-200 colour; user-footer
avatar gets shadow-sm + ring-2 ring-white/30.

Topbar '+ New' uses bg-gradient-brand with shadow-sm + scale-1.02 hover. User
avatar trigger gets shadow-sm + ring-2 ring-white. Notification badge gets
gradient-brand fill + ring-2 ring-background + animate-badge-pop spring keyframe
(retriggers on count change via key={unreadCount}). Command search gets shadow-xs
inset + brand focus ring (ring-4 ring-brand/15).

Adds badge-pop keyframes to tailwind config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:11 +02:00
Matt Ciaccio
22b019a27e merge: PR10c — visual polish (dashboard) (Phase A)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:00 +02:00
Matt Ciaccio
a3424b80d5 style(dashboard): KPITile primitive + gradient PageHeader + tile skeletons
Replaces flat Card-based KPI rendering with KPITile (gradient-brand-soft + accent
stripe). Adds polished gradient PageHeader to DashboardShell with eyebrow, KPI
sub-line, description. Tile-shaped skeletons replace the four CardSkeletons during
KPI load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:09:59 +02:00
Matt Ciaccio
5bcdfefde3 merge: PR10b — visual polish (detail pages) (Phase A)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:09:54 +02:00
Matt Ciaccio
22f944fde2 style(detail): apply gradient header strip to client/interest/yacht/company/berth/residential/invoice details
Adds shared <DetailHeaderStrip> wrapper (rounded-xl + gradient-brand-soft + shadow-xs)
and applies it to every legacy domain detail header. Residential client/interest and
invoice detail get an inline gradient strip with eyebrow ('Residential Client',
'Residential Interest', 'Invoice'). Residential bodies normalized to lg:grid-cols-[2fr_1fr]
per spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:09:47 +02:00
Matt Ciaccio
cda44e721b fix(layout): hoist TooltipProvider to wrap full sidebar tree
The collapsed-state user-footer renders a Tooltip that was outside the
TooltipProvider — the provider only wrapped the nav. Once the sidebar
toggled to collapsed, the footer Tooltip threw "Tooltip must be used
within TooltipProvider", surfacing as console errors in exhaustive
click-through tests.

Move TooltipProvider up one level so every Tooltip in the sidebar tree
(nav items + user footer) is covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 05:08:01 +02:00
Matt Ciaccio
0406778c44 fix(api): kill currentPortId persist race + dedupe admin/ports stampede
The dashboard and residential interest smoke tests were intermittently
failing with the page rendering empty/skeleton state. Root causes:

1. ui-store persisted currentPortId/Slug, but those are URL-derived state.
   After login lands on /<first-port-by-name>/dashboard, localStorage holds
   that port. Hard-navigating to /port-nimara/... rehydrated the store with
   the stale id, and useQuery fired with the wrong port before
   PortProvider's URL-sync useEffect could correct it. Drop both fields
   from partialize — PortProvider re-derives them from the route every
   navigation.

2. apiFetch's slug-to-port fallback fired N parallel /api/v1/admin/ports
   calls when N components mounted simultaneously with an empty store.
   Dedupe in-flight lookups so a stampede collapses into one round-trip.

Also tightened four flaky smoke tests that depended on a fixed 3s wait or
non-waiting isVisible({timeout}) — replaced with expect(...).toBeVisible
or expect.poll so they handle dev-mode JIT cold-start delays cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 04:38:57 +02:00
Matt Ciaccio
259cd7b8bb merge: PR11 — realapi spec scaffolds (Phase A) 2026-04-28 02:53:55 +02:00
Matt Ciaccio
e42b8fde84 test(realapi): Phase A integration spec scaffolds
Adds four realapi specs that gate cleanly on env: smtp-system-send
(roundtrip + attachment bytes), email-attachments-roundtrip
(cross-port 403), documenso-cancel (in-flight cancel → status flip),
minio-file-lifecycle (upload/list/download/delete byte-equal).
Specs skip without DOCUMENSO_API_*/SMTP/IMAP/MINIO env so they run as
no-ops when those services aren't reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:53:49 +02:00
Matt Ciaccio
f354f4adab merge: PR10 — visual polish (lists) (Phase A) 2026-04-28 02:52:26 +02:00
Matt Ciaccio
38cd36a616 style(lists): apply gradient PageHeader to client/interest/yacht/company/berth lists
Pulls the polished gradient hero strip into the five primary list
surfaces. PR10b-e (detail polish, dashboard/admin polish, email +
notifications polish, mobile responsive sweep) deferred to a follow-up
release per spec risk register since visual baseline regen needs hands-
on iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:52:17 +02:00
Matt Ciaccio
77b6ef5026 merge: PR9 — reminder framework polish (Phase A) 2026-04-28 02:50:06 +02:00
Matt Ciaccio
978df1c4d7 feat(reminders): cadence-aware framework with auto/manual modes
isReminderDue now keys off doc.remindersDisabled and the effective
cadence (per-doc override → template default), dropping the implicit
interests.reminderEnabled gate so non-EOI docs auto-remind correctly.
sendReminderIfAllowed gains an options bag — auto:true keeps the 9-16
window + cadence cooldown for the cron, auto:false bypasses both for
manual UI sends. signerId targets a specific pending signer (must be
next in sequential mode). 7 unit tests cover the cadence math.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:50:00 +02:00
Matt Ciaccio
df0b408b7a merge: PR8 — email attachments + system/user senderType (Phase A) 2026-04-28 02:48:17 +02:00
Matt Ciaccio
1151768159 feat(email): system/user senderType + attachments
Composer validator now takes senderType (system|user) and an
attachments[] array, and the service dispatches across two paths:
the system path uses lib/email/index.ts with port-config noreply
identity and logs signed_doc_emailed when an attachment matches a
document's signed PDF; the user path stays on the existing personal-
account flow but is gated by the new email.allowPersonalAccountSends
toggle and the attachment fileIds are persisted on email_messages.
sendEmail in lib/email accepts attachments and resolves them from
MinIO with cross-port enforcement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:48:11 +02:00
Matt Ciaccio
9e69c13202 merge: PR7 — reservation detail + agreement (Phase A) 2026-04-28 02:45:11 +02:00
Matt Ciaccio
6212c118e5 feat(reservations): detail page with agreement flow + contract mirror
Adds /berth-reservations/[id] with state-aware agreement card (none /
in-flight / completed) and the Generate-agreement entry point that
opens the wizard prefilled. handleDocumentCompleted now mirrors a
signed reservation_agreement onto berth_reservations.contractFileId
so the portal can resolve contracts without joining through documents.
Reservation merge tokens (startDate/endDate/tenureType/termSummary/
signedDate) added to the catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:45:05 +02:00
Matt Ciaccio
6795db9aa8 merge: PR6 — create-document wizard MVP (Phase A) 2026-04-28 02:43:05 +02:00
Matt Ciaccio
d8f0cdd7d2 feat(documents): create-document wizard MVP + service dispatch
Implements createFromWizard and createFromUpload service paths covering
the documenso-template, in-app, and upload pathways. Persists subject
FK, signers, watchers, and the per-document reminder controls
(remindersDisabled / reminderCadenceOverride) introduced in PR1. New
POST /api/v1/documents/wizard route and a functional /documents/new UI
with type/source/template/signers/reminders sections. Drag-handle
reorder, watcher autocomplete picker, and PDF preview defer to the
PR10 polish sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:43:00 +02:00
Matt Ciaccio
2dc53842c0 merge: PR5 — document detail page (Phase A) 2026-04-28 02:39:52 +02:00
Matt Ciaccio
aa15807063 feat(documents): detail page with signers, watchers, activity, actions
Replaces the PR4 stub at /documents/[id] with the full Phase A detail
view: gradient header strip, status-aware action bar (Cancel /
Download / Email signatories), per-signer remind + copy-link, watcher
list with remove, and activity timeline. Adds the supporting endpoints
(cancel, compose-completion-email, watchers GET/POST/DELETE) and
listDocumentWatchers / addDocumentWatcher / removeDocumentWatcher
service helpers. The document GET now serves the aggregator shape
when ?detail=true.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:39:46 +02:00
Matt Ciaccio
2a3fae4d6a merge: PR4 — documents hub page (Phase A) 2026-04-28 02:35:43 +02:00
Matt Ciaccio
da7262f18f feat(documents): hub page with tabs, filters, and live counts
Replaces /documents with the Phase A hub: tabs (All/Awaiting them/
Awaiting me/Completed/Expired) backed by per-tab counts via a new
hub-counts endpoint, signature-only chip, type filter, expandable
signer rows, and real-time invalidation across the eight document
socket events. listDocuments grew tab/watcher/signatureOnly/sent-window
filters; the legacy file browser moved to /documents/files where the
sidebar already linked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:35:36 +02:00
Matt Ciaccio
398d6322f1 merge: PR3 — visual primitives + tokens (Phase A) 2026-04-28 02:25:14 +02:00
Matt Ciaccio
deafc5ef38 feat(ui): visual polish primitives + token additions (Phase A)
Adds the design tokens the polish PRs (10a-e) will draw from:
shadow-xs/sm/md/lg/glow, radius scale tuned to spec, gradient utilities,
spring/smooth eases, and fast/base/slow durations. Introduces
StatusPill, KPITile, and EmptyState primitives plus a polished
PageHeader variant ('gradient') with optional eyebrow + KPI sub-line —
existing PageHeader callers stay on the plain variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:25:08 +02:00
Matt Ciaccio
9b87b14c99 merge: PR2 — Documenso v1/v2 abstraction (Phase A) 2026-04-28 02:22:11 +02:00