Commit Graph

17 Commits

Author SHA1 Message Date
b4e502fedd fix(audit-wave-11): BullMQ jobId plumbing for natural dedup
concurrency-auditor C-2: every queue.add(...) site previously enqueued
without a stable jobId, so a double-dispatch (webhook retry, double-
click on Send, scheduler tick collision) would create two queue jobs
and the downstream worker would deliver twice. BullMQ rejects a
duplicate jobId while the original is still queued or active, so a
stable per-entity key gives at-most-once semantics naturally.

Added jobIds across all 10 enqueue sites:

- email send-invoice → `send-invoice:<invoiceId>`
- notifications invoice-overdue-notify → keyed per UTC day so dupes
  collapse intra-day but tomorrow's run can re-notify if unpaid
- export gdpr-export → keyed on the exportId (unique per request)
- webhooks deliver (3 sites: dispatch, retry, test) → keyed on the
  webhook_deliveries row UUID
- maintenance expense-dedup-scan → keyed on expenseId
- notifications send-notification-email → keyed on notification id
- email send-inquiry-confirmation → keyed on interestId (1 per
  submission)
- email send-inquiry-sales-notification → keyed on interestId+email
  (1 per recipient per submission)
- reports generate-report → keyed on the generated_reports row id

Pure refactor — no UX impact. Closes the BullMQ dedup gap that was
the second half of the concurrency-auditor's CRITICAL-tier findings.

Test fixture update: gdpr-export integration test now asserts the
jobId option on the queue.add call.

Tests 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:02:38 +02:00
f183f58b0c fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson
Address the CRITICAL + high-leverage HIGH items from the types-auditor:

**C1 — `tx: any` in client-restore.service**
Export a canonical `Tx` type from `lib/db/utils.ts` (derived from
Drizzle's `db.transaction` callback shape) and use it in
`applyReversal` so the 12+ downstream tx writes get full inference.

**C2 — berth-detail page stacked `useQuery<any>` escape hatches**
Export `BerthDetailData` from berth-detail-header and consume it
through useQuery + apiFetch. Removed three `any` escapes in the
highest-traffic detail page. Also collapsed the duplicate `BerthData`
in berth-tabs.tsx to import from berth-detail-header so the two
types can't drift.

**C3 — parseBody migration for portal/public routes**
Replace raw `await req.json() + schema.parse(body)` with the
project-standard `parseBody(req, schema)` helper across 7 routes:
- portal/auth/{change-password, activate, reset-password}
- auth/set-password
- public/{interests, residential-inquiries}
Skipped the three anti-enumeration routes (forgot-password, sign-in,
sign-in-by-identifier) where the manual validation gives opaque
errors on purpose. website-inquiries already wraps the parse in a
custom 400 — left as-is.

**HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)**
Introduce `toAuditJson<T extends object>(row: T): Record<string,
unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow`
that already exists for the same reason). Codemod 21 `<row> as unknown
as Record<string, unknown>` sites across:
- invoices.ts × 6
- expenses.ts × 6
- berths.service × 2
- documents.service × 2
- ocr-config.service × 2
- ai-budget.service × 2
- yachts.service, companies.service, company-memberships.service × 1 each

document-templates' `payload as unknown as Record<...>` is a different
shape (Documenso form-values widening, not an audit log) — kept the
manual cast there. Tests stay 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00
ed2424cc68 feat(invoices): remove client-facing PDF generation
Phase 1 / commit 11 of 14 — invoices are client-facing documents, and
per the new "no CRM-generated client-facing PDFs" rule (see the design
spec), the in-app pdfme rendering is removed entirely.

Future invoice rendering will use the deferred AcroForm-fill admin-
template feature: admin uploads a PDF template with named form fields,
CRM fills them with invoice data via pdf-lib. Same pattern as the
in-app EOI pathway. Tracked in BACKLOG.md.

Deleted:
  - src/lib/services/invoices.ts:generateInvoicePdf (60 LOC)
  - src/lib/pdf/templates/invoice-template.ts (entire pdfme template)
  - src/app/api/v1/invoices/[id]/generate-pdf/route.ts
  - src/components/invoices/invoice-pdf-preview.tsx (regenerate UI)
  - "PDF Preview" tab on invoice detail page
  - 5 now-unused imports in invoices.ts (files, ports, buildStoragePath,
    getStorageBackend, env)

sendInvoice() retained: still queues the send-invoice email job, still
flips status to "sent", still emits the socket event. The PDF-attach
step is gone — downstream consumers either render externally or wait
for the AcroForm-fill feature. The `pdfFileId` column on invoices stays
so existing rows don't break, just never gets written by this code path.

1319/1319 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:04:49 +02:00
acf878f997 feat(deps): bump zod 3→4 + @hookform/resolvers 3→5
Resolved 65 type errors across the codebase via these v4 migration
patterns:

- `ZodError.errors` renamed to `ZodError.issues` (4 call sites in auth
  routes + central error handler).
- `z.record(value)` now requires explicit key type: `z.record(z.string(),
  value)`. Updated 7 sites across templates / forms / saved-views /
  website-inquiries.
- `.refine(check, msgFn)` second-arg shape changed — now requires an
  `{ error: (issue) => ... }` object form. Updated
  `mergeFieldsSchema` in document-templates validator.
- `.transform(...).default(...)` chains: v4 enforces default value type
  matches transform OUTPUT. Reordered to `.default(...).transform(...)`
  in list-query / company-memberships handlers.
- `z.coerce.*()` INPUT type widened to `unknown` in v4. Service signatures
  using `z.input<typeof schema>` (kept for caller flexibility around
  defaults) now re-parse via `schema.parse(data)` to recover the
  post-coercion shape Drizzle needs. Done in berth-reservations service.
  Invoice service narrows `lineItems` locally with a typed cast since
  re-parsing would double-validate.
- `.optional().transform(...)` no longer propagates the optional marker
  through v4's new ZodPipe. Moved `.optional()` to the END of chain in
  `optionalDesiredDimSchema` (interests) and documents list query
  (folderId, signatureOnly).
- ZodIssue subtype shapes simplified: `received` removed from
  invalid_type, `type` renamed to `origin` on too_small. Test fixtures
  updated.
- @hookform/resolvers v5 splits Resolver into 3-generic form (Input,
  Context, Output). useForm calls in 6 forms (client, yacht, berth,
  interest, expense, invoices-new-page) now pass explicit generics:
  `useForm<z.input<typeof schema>, unknown, z.infer<typeof schema>>`.

Verified: tsc clean (0 errors), vitest 1293/1293 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:29:03 +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
Matt Ciaccio
4eea4ceff9 fix(audit-tier-4): tenant-isolation defense-in-depth
Closes the audit's HIGH §10 + MED §§17–22 isolation footguns. None of
these are user-impactful TODAY — every site is preceded by a port-
scoped read or pre-validated by ctx.portId — but each is a future-
refactor accident waiting to happen, so the SQL itself now pins the
tenant boundary:

* mergeClients gains a callerPortId option; the route caller passes
  ctx.portId.  removeInterestBerth now requires portId and verifies
  both the interest and the berth share it before deleting the
  junction row.  All three callers updated.
* Six service mutations now scope the WHERE to (id, portId):
  form-templates update + delete, invoices.detectOverdue per-row
  update, notifications.markRead, clients.deleteRelationship.
  company-memberships uses an inArray sub-select against port
  companies (no port_id column on the table itself), covering
  updateMembership / endMembership / setPrimary.
* Port-scoped file lookups in portal.getDocumentDownloadUrl,
  reports.getDownloadUrl (file presign), berth-reservations.activate
  (contractFileId attach guard), and residential.getResidentialInterestById
  (residentialClient join).

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §10 + MED §§17–22
(auditor-B3 Issues 1–5,7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:48:13 +02:00
Matt Ciaccio
fc7595faf8 fix(audit-tier-2): error-surface hygiene — toastError + CodedError sweep
Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings:

* 38 client components / 56 toast.error sites converted to
  toastError(err) so the new admin error inspector becomes usable from
  user-reported issues — every failed inline-edit, save, send, archive,
  upload, etc. now carries the request-id + error-code (Copy ID action).
* 26 service files / 62 bare-Error throws converted to CodedError or
  the existing AppError subclasses.  Adds new error codes:
  DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502),
  DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502),
  IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502),
  UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for
  post-insert returning-empty guards.
* Five vitest assertions updated to match the new user-facing wording
  (client-merge "already been merged", expense/interest "couldn't find
  that …", documenso "signing service didn't respond").

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1)
+ MED §11 (auditor-G Issue 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:18:05 +02:00
Matt Ciaccio
cf430d70c3 fix(storage): route every file op through getStorageBackend()
Removes 12 direct minioClient.{put,get,remove}Object call sites that
bypassed the pluggable storage abstraction.  Filesystem-mode deploys
(MULTI_NODE_DEPLOYMENT=false, storage_backend=filesystem) silently
broke at every site: GDPR export, invoice PDF, EOI generation, portal
download, file upload, folder create/rename/delete, signed PDF land,
maintenance cleanup, etc.  Each site now resolves the active backend
and uses its put/get/delete + the new presignDownloadUrl() helper.

Folder marker objects in /files/folders/* keep the same on-the-wire
shape but route through the backend.  A future refactor should move
folder bookkeeping to a DB-backed virtual-folder table (see audit
HIGH §3 follow-up note in the route file).

Sites left untouched: src/lib/services/system-monitoring.service.ts
and src/app/api/ready/route.ts use minioClient.bucketExists as an S3-
specific health probe — those are correctly mode-aware and stay.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §3 (auditor-D Issue 1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:41:02 +02:00
Matt Ciaccio
8699f81879 chore(style): codebase em-dash sweep + minor layout polish
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m18s
Build & Push Docker Images / build-and-push (push) Has been skipped
Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).

Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
  pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
  port switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:57:01 +02:00
Matt Ciaccio
ba5fb6db5e feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes
Three independent strengthenings of the sales spine that the prior coherence
sweep made it possible to do cleanly.

  1. EOI queue page

     - Sidebar entry under Documents → "EOI queue".
     - Route /[port]/documents/eoi renders DocumentsHub with the existing
       eoi_queue tab pre-selected (filters in-flight EOIs only).
     - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi
       route is no longer silently excluded.

  2. Invoice ↔ deposit link

     - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind
       ('general' | 'deposit'). Indexed on (port_id, interest_id).
     - createInvoiceSchema requires interestId when kind === 'deposit';
       the service validates the linked interest belongs to the same port
       before insert.
     - recordPayment auto-advances pipelineStage to deposit_10pct (via
       advanceStageIfBehind) when a paid invoice is kind=deposit and has
       an interestId. No-op if the interest is already further along.
     - "Create deposit invoice" link added to the Deposit milestone on the
       interest detail. Links to /invoices/new?interestId=…&kind=deposit;
       the form prefills the billing entity from the linked interest's
       client and shows a context banner.

  3. Won / lost terminal outcomes

     - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified'
       | 'lost_no_response' | 'cancelled') + outcomeReason text +
       outcomeAt timestamp. Indexed on (port_id, outcome).
     - setInterestOutcome / clearInterestOutcome services + POST/DELETE
       /api/v1/interests/:id/outcome endpoints (gated by change_stage
       permission). Setting an outcome moves the interest to `completed`
       in the same write; clearing reopens to `in_communication` (or a
       caller-specified stage).
     - Mark Won / Mark Lost icon buttons on the interest detail header,
       plus an outcome badge that replaces the stage pill once a terminal
       outcome is set, plus a Reopen button.
     - Funnel + dashboard math updated to exclude lost/cancelled outcomes
       from active calculations (KPIs.activeInterests, pipelineValueUsd,
       getPipelineCounts, computePipelineFunnel, getRevenueForecast).
       The funnel now also returns a `lost` summary so callers can
       surface leakage without polluting conversion percentages.

Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB
manually via psql because drizzle-kit push hits a pre-existing zod
parsing issue on the companies index. Dev server may need a restart
to flush prepared-statement caches.

tsc clean. vitest 832/832 pass. ESLint clean on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
Matt Ciaccio
e06fb9545b sec: lock down 5 cross-tenant IDORs uncovered in second-pass review
1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin
   (manage_settings) mutate any other tenant's port row by passing the
   foreign id in the path. Now non-super-admins must target their own
   ctx.portId; listPorts and createPort are super-admin only.

2. HIGH — Invoice create/update accepted arbitrary expenseIds and
   linked them into invoice_expenses with no port check; the GET
   response then re-emitted those foreign expense rows via the
   linkedExpenses join. assertExpensesInPort now validates each id
   belongs to the caller's portId before insert; getInvoiceById's
   join filters by expenses.portId as defense-in-depth.

3. HIGH — Document creation paths (createDocument, createFromWizard,
   createFromUpload) persisted user-supplied clientId/interestId/
   companyId/yachtId/reservationId without verifying those FKs were
   in-port. sendForSigning then loaded the foreign client/interest by
   id alone and pushed their PII into the Documenso payload. New
   assertSubjectFksInPort helper rejects out-of-port FKs at create
   time; sendForSigning's interest+client lookups now also filter by
   portId.

4. MEDIUM — calculateInterestScore read its redis cache before
   verifying portId, and the cache key was interestId-only — a
   foreign-port caller could observe a cached score breakdown.
   Cache key now includes portId, and the port-scope DB lookup runs
   before any cache.get.

5. MEDIUM — AI email-draft job results were retrievable by anyone who
   could guess the BullMQ jobId (default sequential integers). Job
   ids are now random UUIDs, requestEmailDraft validates interestId/
   clientId belong to ctx.portId before enqueueing, the worker's
   client lookup is port-scoped, and getEmailDraftResult requires
   the caller to match the original requester's userId+portId before
   returning the drafted subject/body.

The interest-scoring unit test that asserted "DB is bypassed on cache
hit" is updated to reflect the new (security-correct) ordering.
Two new regression test files cover the email-draft binding (5 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:48:43 +02:00
Matt Ciaccio
5d29bfc153 refactor(services): centralize AuditMeta + transactional setEntityTags helper
The same `interface AuditMeta { userId; portId; ipAddress; userAgent }`
was duplicated in 26 service files. Move the canonical definition into
`@/lib/audit` next to the related types and update every service to
import it. `ServiceAuditMeta` (the alias used in invoices.ts and
expenses.ts) collapses into the same name.

Tag CRUD across clients/companies/yachts/interests/berths followed an
identical wipe-then-rewrite recipe with two latent issues: the delete
and insert weren't wrapped in a transaction (a partial failure left
the entity with zero tags) and the audit-log payload shape diverged
(`newValue: { tagIds }` for clients/yachts/companies but
`metadata: { type: 'tags_updated', tagIds }` for interests/berths).

Extract `setEntityTags` in `entity-tags.helper.ts` that performs the
delete+insert inside a single transaction, normalizes the audit payload
to `newValue: { tagIds }`, and dispatches the per-entity socket event
through a switch so `ServerToClientEvents` typing stays intact.

The five `setXTags(...)` service functions now do parent-row tenant
verification and delegate the join-table work + side effects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:58:42 +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
9d7decfc5b feat(invoices): polymorphic billing entity with snapshot clientName
Wires the billingEntityType/billingEntityId columns (added in PR 1) through
the invoice validator and service. Clients can now be billed as either a
client or a company; clientName becomes a snapshot derived from the entity
at create time.

- createInvoiceSchema: replace clientName with billingEntity {type,id}
- listInvoicesSchema: add billingEntityType/billingEntityId filters
- createInvoice: resolveBillingEntity helper (tenant-scoped; tx-aware)
  falls back to entity primary email/address when not supplied
- listInvoices: honor new billing-entity filters
- updateInvoice: unchanged — billing entity is fixed after create
- invoice wizard step 1: temporary billing-entity id input (Task 10.2
  replaces this with a proper picker)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:02:00 +02:00
082d4f20e3 Fix all TypeScript errors: restore proper types and typed route casts
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m16s
Build & Push Docker Images / build-and-push (push) Failing after 4m42s
- Restore `as any` casts for Next.js typedRoutes on dynamic routes
- Use proper types for PDF templates, invoice/expense data, DB schema
- Fix PgColumn casts in sort helpers for expenses/invoices
- Add null guards for optional port/client in record-export
- Fix vitest config (remove invalid poolOptions)
- Lint: 0 errors, TypeScript: 0 errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:29:55 +01:00
4c20bcffcd Fix all ESLint errors: remove unused imports, replace any types
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m10s
Build & Push Docker Images / build-and-push (push) Has been skipped
Build & Push Docker Images / deploy (push) Has been skipped
- Remove ~60 unused imports and variables across 88 files
- Replace ~80 `any` type annotations with proper types (unknown,
  Record<string, unknown>, or specific types)
- Prefix unused callback args with underscore
- Fix unescaped JSX entities
- Lint now passes cleanly (0 errors, 2 intentional img warnings)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:06:18 +01:00
67d7e6e3d5 Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00