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>
- maintenance worker now expires GDPR export bundles (db row + MinIO object)
on the gdpr_exports.expires_at boundary, plus 90-day retention sweep on
ai_usage_ledger; both jobs scheduled daily.
- portId scoping added to listClientRelationships and listClientExports
(defense-in-depth — parent-resource gates already prevent cross-tenant
reads, but service layer should enforce on its own).
- SELECT FOR UPDATE on parent client/company row inside add/update address
transactions to serialize concurrent isPrimary toggles.
- public /interests + /residential-inquiries endpoints swap their
in-memory ipHits maps for the redis sliding-window limiter via the
new rateLimiters.publicForm config (5/hr/IP), so the cap survives
restarts and is shared across worker processes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
listCompanies returns memberCount (active companyMemberships)
and yachtCount (yachts where currentOwnerType=company), each
fetched as a parallel grouped count after the main page query.
Two new badge columns in company-columns render them between
the tax-id and status columns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>