feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm';
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
2026-04-28 19:38:43 +02:00
|
|
|
import {
|
|
|
|
|
clients,
|
|
|
|
|
clientContacts,
|
|
|
|
|
clientRelationships,
|
|
|
|
|
clientTags,
|
|
|
|
|
clientAddresses,
|
|
|
|
|
} from '@/lib/db/schema/clients';
|
2026-04-24 14:31:14 +02:00
|
|
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
|
|
|
|
import { yachts } from '@/lib/db/schema/yachts';
|
|
|
|
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
import { tags } from '@/lib/db/schema/system';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
import { NotFoundError } from '@/lib/errors';
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
import { emitToRoom } from '@/lib/socket/server';
|
|
|
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
|
|
|
import { diffEntity } from '@/lib/entity-diff';
|
|
|
|
|
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
|
|
|
|
|
import type {
|
|
|
|
|
CreateClientInput,
|
|
|
|
|
UpdateClientInput,
|
|
|
|
|
ListClientsInput,
|
|
|
|
|
} from '@/lib/validators/clients';
|
|
|
|
|
|
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listClients(portId: string, query: ListClientsInput) {
|
2026-04-24 14:31:14 +02:00
|
|
|
const { page, limit, sort, order, search, includeArchived, source, nationality, tagIds } = query;
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
|
|
|
|
|
const filters = [];
|
|
|
|
|
|
|
|
|
|
if (source) {
|
|
|
|
|
filters.push(eq(clients.source, source));
|
|
|
|
|
}
|
|
|
|
|
if (nationality) {
|
2026-04-29 01:52:41 +02:00
|
|
|
// Filter accepts an ISO-3166-1 alpha-2 code.
|
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
|
|
|
filters.push(eq(clients.nationalityIso, nationality.toUpperCase()));
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
}
|
|
|
|
|
if (tagIds && tagIds.length > 0) {
|
|
|
|
|
const clientsWithTags = await db
|
|
|
|
|
.selectDistinct({ clientId: clientTags.clientId })
|
|
|
|
|
.from(clientTags)
|
|
|
|
|
.where(inArray(clientTags.tagId, tagIds));
|
|
|
|
|
const matchingIds = clientsWithTags.map((r) => r.clientId);
|
|
|
|
|
if (matchingIds.length > 0) {
|
|
|
|
|
filters.push(inArray(clients.id, matchingIds));
|
|
|
|
|
} else {
|
|
|
|
|
// No clients match these tags — return empty
|
|
|
|
|
return { data: [], total: 0 };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let sortColumn: typeof clients.fullName | typeof clients.createdAt | typeof clients.updatedAt =
|
|
|
|
|
clients.updatedAt;
|
|
|
|
|
if (sort === 'fullName') sortColumn = clients.fullName;
|
|
|
|
|
else if (sort === 'createdAt') sortColumn = clients.createdAt;
|
|
|
|
|
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
const result = await buildListQuery<typeof clients.$inferSelect>({
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
table: clients,
|
|
|
|
|
portIdColumn: clients.portId,
|
|
|
|
|
portId,
|
|
|
|
|
idColumn: clients.id,
|
|
|
|
|
updatedAtColumn: clients.updatedAt,
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
searchColumns: [clients.fullName],
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
searchTerm: search,
|
|
|
|
|
filters,
|
|
|
|
|
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
|
|
|
|
page,
|
|
|
|
|
pageSize: limit,
|
|
|
|
|
includeArchived,
|
|
|
|
|
archivedAtColumn: clients.archivedAt,
|
|
|
|
|
});
|
|
|
|
|
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
if (result.data.length === 0) return result;
|
|
|
|
|
|
|
|
|
|
const ids = result.data.map((r) => r.id);
|
|
|
|
|
|
|
|
|
|
const [yachtCounts, companyCounts] = await Promise.all([
|
|
|
|
|
db
|
|
|
|
|
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
|
|
|
|
.from(yachts)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(yachts.portId, portId),
|
|
|
|
|
eq(yachts.currentOwnerType, 'client'),
|
|
|
|
|
inArray(yachts.currentOwnerId, ids),
|
|
|
|
|
isNull(yachts.archivedAt),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.groupBy(yachts.currentOwnerId),
|
|
|
|
|
db
|
|
|
|
|
.select({ clientId: companyMemberships.clientId, count: count() })
|
|
|
|
|
.from(companyMemberships)
|
|
|
|
|
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
|
|
|
|
|
.groupBy(companyMemberships.clientId),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
|
|
|
|
|
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...result,
|
|
|
|
|
data: result.data.map((row) => ({
|
|
|
|
|
...row,
|
|
|
|
|
yachtCount: yachtCountMap.get(row.id) ?? 0,
|
|
|
|
|
companyCount: companyCountMap.get(row.id) ?? 0,
|
|
|
|
|
})),
|
|
|
|
|
};
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Get by ID ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function getClientById(id: string, portId: string) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, id),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!client || client.portId !== portId) {
|
|
|
|
|
throw new NotFoundError('Client');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const contacts = await db.query.clientContacts.findMany({
|
|
|
|
|
where: eq(clientContacts.clientId, id),
|
|
|
|
|
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-28 19:38:43 +02:00
|
|
|
const addresses = await db.query.clientAddresses.findMany({
|
|
|
|
|
where: eq(clientAddresses.clientId, id),
|
|
|
|
|
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
|
|
|
|
});
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
const clientTagRows = await db
|
|
|
|
|
.select({ tag: tags })
|
|
|
|
|
.from(clientTags)
|
|
|
|
|
.innerJoin(tags, eq(clientTags.tagId, tags.id))
|
|
|
|
|
.where(eq(clientTags.clientId, id));
|
|
|
|
|
|
2026-04-24 14:31:14 +02:00
|
|
|
const yachtRows = await db.query.yachts.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(yachts.portId, portId),
|
|
|
|
|
eq(yachts.currentOwnerType, 'client'),
|
|
|
|
|
eq(yachts.currentOwnerId, id),
|
|
|
|
|
isNull(yachts.archivedAt),
|
|
|
|
|
),
|
|
|
|
|
columns: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
hullNumber: true,
|
|
|
|
|
registration: true,
|
|
|
|
|
lengthFt: true,
|
|
|
|
|
widthFt: true,
|
|
|
|
|
status: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const membershipRows = await db
|
|
|
|
|
.select({
|
|
|
|
|
membershipId: companyMemberships.id,
|
|
|
|
|
role: companyMemberships.role,
|
|
|
|
|
isPrimary: companyMemberships.isPrimary,
|
|
|
|
|
startDate: companyMemberships.startDate,
|
|
|
|
|
company: {
|
|
|
|
|
id: companies.id,
|
|
|
|
|
name: companies.name,
|
|
|
|
|
legalName: companies.legalName,
|
|
|
|
|
status: companies.status,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
.from(companyMemberships)
|
|
|
|
|
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(companyMemberships.clientId, id),
|
|
|
|
|
eq(companies.portId, portId),
|
|
|
|
|
isNull(companyMemberships.endDate),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const activeReservations = await db.query.berthReservations.findMany({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(berthReservations.clientId, id),
|
|
|
|
|
eq(berthReservations.portId, portId),
|
|
|
|
|
eq(berthReservations.status, 'active'),
|
|
|
|
|
),
|
|
|
|
|
columns: {
|
|
|
|
|
id: true,
|
|
|
|
|
berthId: true,
|
|
|
|
|
yachtId: true,
|
|
|
|
|
startDate: true,
|
|
|
|
|
tenureType: true,
|
|
|
|
|
status: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
const portalEnabled = await isPortalEnabledForPort(portId);
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
return {
|
|
|
|
|
...client,
|
|
|
|
|
contacts,
|
2026-04-28 19:38:43 +02:00
|
|
|
addresses,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
tags: clientTagRows.map((r) => r.tag),
|
2026-04-24 14:31:14 +02:00
|
|
|
yachts: yachtRows,
|
|
|
|
|
companies: membershipRows,
|
|
|
|
|
activeReservations,
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
clientPortalEnabled: portalEnabled,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Create ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-24 14:31:14 +02:00
|
|
|
export async function createClient(portId: string, data: CreateClientInput, meta: AuditMeta) {
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
const result = await withTransaction(async (tx) => {
|
|
|
|
|
const { contacts: contactsInput, tagIds, ...clientData } = data;
|
|
|
|
|
|
|
|
|
|
const [client] = await tx
|
|
|
|
|
.insert(clients)
|
|
|
|
|
.values({ portId, ...clientData })
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (contactsInput.length > 0) {
|
2026-04-24 14:31:14 +02:00
|
|
|
await tx
|
|
|
|
|
.insert(clientContacts)
|
|
|
|
|
.values(contactsInput.map((c) => ({ clientId: client!.id, ...c })));
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (tagIds && tagIds.length > 0) {
|
2026-04-24 14:31:14 +02:00
|
|
|
await tx.insert(clientTags).values(tagIds.map((tagId) => ({ clientId: client!.id, tagId })));
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return client!;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'client',
|
|
|
|
|
entityId: result.id,
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
newValue: { fullName: result.fullName },
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 14:31:14 +02:00
|
|
|
emitToRoom(`port:${portId}`, 'client:created', {
|
|
|
|
|
clientId: result.id,
|
|
|
|
|
clientName: result.fullName ?? '',
|
|
|
|
|
source: result.source ?? '',
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
|
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
|
|
|
dispatchWebhookEvent(portId, 'client:created', { clientId: result.id }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Update ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function updateClient(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: UpdateClientInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const existing = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, id),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
|
|
|
throw new NotFoundError('Client');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { diff } = diffEntity(existing as Record<string, unknown>, data as Record<string, unknown>);
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(clients)
|
|
|
|
|
.set({ ...data, updatedAt: new Date() })
|
|
|
|
|
.where(and(eq(clients.id, id), eq(clients.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'client',
|
|
|
|
|
entityId: id,
|
2026-03-26 12:06:18 +01:00
|
|
|
oldValue: diff as Record<string, unknown>,
|
|
|
|
|
newValue: data as Record<string, unknown>,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-24 14:31:14 +02:00
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', {
|
|
|
|
|
clientId: id,
|
|
|
|
|
changedFields: Object.keys(diff),
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
|
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
|
|
|
dispatchWebhookEvent(portId, 'client:updated', { clientId: id }),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Archive / Restore ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function archiveClient(id: string, portId: string, meta: AuditMeta) {
|
|
|
|
|
const existing = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, id),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
|
|
|
throw new NotFoundError('Client');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await softDelete(clients, clients.id, id);
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'archive',
|
|
|
|
|
entityType: 'client',
|
|
|
|
|
entityId: id,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
|
|
|
|
|
|
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
|
|
|
dispatchWebhookEvent(portId, 'client:archived', { clientId: id }),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function restoreClient(id: string, portId: string, meta: AuditMeta) {
|
|
|
|
|
const existing = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, id),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
|
|
|
throw new NotFoundError('Client');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await restore(clients, clients.id, id);
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'restore',
|
|
|
|
|
entityType: 'client',
|
|
|
|
|
entityId: id,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'client:restored', { clientId: id });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Contacts ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listContacts(clientId: string, portId: string) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
|
|
|
|
return db.query.clientContacts.findMany({
|
|
|
|
|
where: eq(clientContacts.clientId, clientId),
|
|
|
|
|
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function addContact(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
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
|
|
|
data: {
|
|
|
|
|
channel: string;
|
|
|
|
|
value: string;
|
|
|
|
|
valueE164?: string | null;
|
|
|
|
|
valueCountry?: string | null;
|
|
|
|
|
label?: string;
|
|
|
|
|
isPrimary?: boolean;
|
|
|
|
|
notes?: string;
|
|
|
|
|
},
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
|
|
|
|
const [contact] = await db
|
|
|
|
|
.insert(clientContacts)
|
|
|
|
|
.values({ clientId, ...data })
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'clientContact',
|
|
|
|
|
entityId: contact!.id,
|
|
|
|
|
newValue: { clientId, channel: contact!.channel },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
|
|
|
|
|
|
|
|
|
return contact!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateContact(
|
|
|
|
|
contactId: string,
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
2026-04-24 14:31:14 +02:00
|
|
|
data: Partial<{
|
|
|
|
|
channel: string;
|
|
|
|
|
value: string;
|
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
|
|
|
valueE164: string | null;
|
|
|
|
|
valueCountry: string | null;
|
2026-04-24 14:31:14 +02:00
|
|
|
label: string;
|
|
|
|
|
isPrimary: boolean;
|
|
|
|
|
notes: string;
|
|
|
|
|
}>,
|
2026-03-26 12:06:18 +01:00
|
|
|
_meta: AuditMeta,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
|
|
|
|
const contact = await db.query.clientContacts.findFirst({
|
|
|
|
|
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
|
|
|
|
|
});
|
|
|
|
|
if (!contact) throw new NotFoundError('Contact');
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(clientContacts)
|
|
|
|
|
.set({ ...data, updatedAt: new Date() })
|
|
|
|
|
.where(eq(clientContacts.id, contactId))
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
|
|
|
|
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function removeContact(
|
|
|
|
|
contactId: string,
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
2026-03-26 12:06:18 +01:00
|
|
|
_meta: AuditMeta,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
|
|
|
|
const contact = await db.query.clientContacts.findFirst({
|
|
|
|
|
where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
|
|
|
|
|
});
|
|
|
|
|
if (!contact) throw new NotFoundError('Contact');
|
|
|
|
|
|
|
|
|
|
await db.delete(clientContacts).where(eq(clientContacts.id, contactId));
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 19:38:43 +02:00
|
|
|
// ─── Addresses ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface AddressInput {
|
|
|
|
|
label?: string;
|
|
|
|
|
streetAddress?: string | null;
|
|
|
|
|
city?: string | null;
|
|
|
|
|
subdivisionIso?: string | null;
|
|
|
|
|
postalCode?: string | null;
|
|
|
|
|
countryIso?: string | null;
|
|
|
|
|
isPrimary?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function listClientAddresses(clientId: string, portId: string) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
|
|
|
|
return db.query.clientAddresses.findMany({
|
|
|
|
|
where: eq(clientAddresses.clientId, clientId),
|
|
|
|
|
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function addClientAddress(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: AddressInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
|
|
|
|
// The unique partial index requires us to demote any existing primary
|
2026-04-29 01:52:41 +02:00
|
|
|
// before inserting a new one. We grab a row lock on the client to
|
|
|
|
|
// serialize concurrent primary-toggle requests against the same client —
|
|
|
|
|
// without this, two simultaneous "isPrimary=true" inserts can both
|
|
|
|
|
// observe "no existing primary" and one trips the unique index with a
|
|
|
|
|
// 5xx instead of being safely ordered.
|
2026-04-28 19:38:43 +02:00
|
|
|
const address = await withTransaction(async (tx) => {
|
2026-04-29 01:52:41 +02:00
|
|
|
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
|
|
|
|
|
|
2026-04-28 19:38:43 +02:00
|
|
|
const wantsPrimary = data.isPrimary ?? false;
|
|
|
|
|
if (wantsPrimary) {
|
|
|
|
|
await tx
|
|
|
|
|
.update(clientAddresses)
|
|
|
|
|
.set({ isPrimary: false })
|
|
|
|
|
.where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)));
|
|
|
|
|
}
|
|
|
|
|
const [row] = await tx
|
|
|
|
|
.insert(clientAddresses)
|
|
|
|
|
.values({
|
|
|
|
|
clientId,
|
|
|
|
|
portId,
|
|
|
|
|
label: data.label ?? 'Primary',
|
|
|
|
|
streetAddress: data.streetAddress ?? null,
|
|
|
|
|
city: data.city ?? null,
|
|
|
|
|
subdivisionIso: data.subdivisionIso ?? null,
|
|
|
|
|
postalCode: data.postalCode ?? null,
|
|
|
|
|
countryIso: data.countryIso ?? null,
|
|
|
|
|
isPrimary: wantsPrimary,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
return row!;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'clientAddress',
|
|
|
|
|
entityId: address.id,
|
|
|
|
|
newValue: { clientId, label: address.label, countryIso: address.countryIso },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
|
|
|
|
|
|
|
|
|
|
return address;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateClientAddress(
|
|
|
|
|
addressId: string,
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: AddressInput,
|
|
|
|
|
_meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
|
|
|
|
const existing = await db.query.clientAddresses.findFirst({
|
|
|
|
|
where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Address');
|
|
|
|
|
|
|
|
|
|
const updated = await withTransaction(async (tx) => {
|
2026-04-29 01:52:41 +02:00
|
|
|
// Lock the client row to serialize primary-toggle changes — see addClientAddress.
|
|
|
|
|
await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');
|
|
|
|
|
|
2026-04-28 19:38:43 +02:00
|
|
|
if (data.isPrimary === true && !existing.isPrimary) {
|
|
|
|
|
await tx
|
|
|
|
|
.update(clientAddresses)
|
|
|
|
|
.set({ isPrimary: false })
|
|
|
|
|
.where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)));
|
|
|
|
|
}
|
|
|
|
|
const [row] = await tx
|
|
|
|
|
.update(clientAddresses)
|
|
|
|
|
.set({ ...data, updatedAt: new Date() })
|
|
|
|
|
.where(eq(clientAddresses.id, addressId))
|
|
|
|
|
.returning();
|
|
|
|
|
return row!;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
|
|
|
|
|
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function removeClientAddress(
|
|
|
|
|
addressId: string,
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
_meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
|
|
|
|
const address = await db.query.clientAddresses.findFirst({
|
|
|
|
|
where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)),
|
|
|
|
|
});
|
|
|
|
|
if (!address) throw new NotFoundError('Address');
|
|
|
|
|
|
|
|
|
|
await db.delete(clientAddresses).where(eq(clientAddresses.id, addressId));
|
|
|
|
|
|
|
|
|
|
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function setClientTags(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
tagIds: string[],
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
await setEntityTags({
|
|
|
|
|
joinTable: clientTags,
|
|
|
|
|
entityColumn: clientTags.clientId,
|
|
|
|
|
tagColumn: clientTags.tagId,
|
|
|
|
|
entityId: clientId,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
portId,
|
2026-04-29 01:58:42 +02:00
|
|
|
tagIds,
|
|
|
|
|
meta,
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
entityType: 'client',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Relationships ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listRelationships(clientId: string, portId: string) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
|
|
|
|
return db.query.clientRelationships.findMany({
|
2026-04-29 01:52:41 +02:00
|
|
|
where: (r, { and, or, eq }) =>
|
|
|
|
|
and(eq(r.portId, portId), or(eq(r.clientAId, clientId), eq(r.clientBId, clientId))),
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createRelationship(
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: { clientBId: string; relationshipType: string; description?: string },
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const client = await db.query.clients.findFirst({
|
|
|
|
|
where: eq(clients.id, clientId),
|
|
|
|
|
});
|
|
|
|
|
if (!client || client.portId !== portId) throw new NotFoundError('Client');
|
|
|
|
|
|
|
|
|
|
const [rel] = await db
|
|
|
|
|
.insert(clientRelationships)
|
|
|
|
|
.values({ portId, clientAId: clientId, ...data })
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'clientRelationship',
|
|
|
|
|
entityId: rel!.id,
|
|
|
|
|
newValue: { clientAId: clientId, clientBId: data.clientBId, type: data.relationshipType },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return rel!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteRelationship(
|
|
|
|
|
relId: string,
|
|
|
|
|
clientId: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const rel = await db.query.clientRelationships.findFirst({
|
|
|
|
|
where: eq(clientRelationships.id, relId),
|
|
|
|
|
});
|
|
|
|
|
if (!rel || rel.portId !== portId) throw new NotFoundError('Relationship');
|
|
|
|
|
|
|
|
|
|
await db.delete(clientRelationships).where(eq(clientRelationships.id, relId));
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'delete',
|
|
|
|
|
entityType: 'clientRelationship',
|
|
|
|
|
entityId: relId,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Find Duplicates ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function findDuplicates(portId: string, fullName: string) {
|
|
|
|
|
return db.query.clients.findMany({
|
2026-04-24 14:31:14 +02:00
|
|
|
where: (c, { and, eq }) => and(eq(c.portId, portId), ilike(c.fullName, `%${fullName}%`)),
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
limit: 5,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Options (for comboboxes) ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listClientOptions(portId: string, search?: string) {
|
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
|
|
|
// Pickers only surface active rows. Archived clients are still resolvable
|
|
|
|
|
// by id (e.g. history views) but should not appear in dropdowns.
|
|
|
|
|
const conditions = [eq(clients.portId, portId), isNull(clients.archivedAt)];
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
if (search) {
|
refactor(clients): drop deprecated yacht/company/proxy columns
PR 13: now that all reads are migrated to the dedicated yacht / company
/ membership entities, drop the columns that mirrored them on `clients`:
companyName, isProxy, proxyType, actualOwnerName, relationshipNotes,
yachtName, yachtLength{Ft,M}, yachtWidth{Ft,M}, yachtDraft{Ft,M},
berthSizeDesired.
Migration `0008_loud_ikaris.sql` issues the destructive ALTER TABLE
DROP COLUMN statements. Run `pnpm db:push` (or the migration runner) to
apply.
Caller cleanup (zero behavioral change to remaining flows):
- Drops the legacy `generateEoi` flow entirely (route, service function,
pdfme template, validator schema). The dual-path generate-and-sign
service from PR 11 has fully replaced it; the route was no longer
wired to the UI.
- `clients.service`: company-name search column / WHERE / audit value
removed; search now ranks by full name only.
- `interests.service`: `resolveLeadCategory` reads dimensions from
`yachts` via `interest.yachtId` instead of the dropped
`client.yachtLength{Ft,M}`.
- `record-export`: client-summary now lists yachts via owner-side
lookup (direct + active company memberships); interest-summary fetches
yacht via `interest.yachtId`. Both PDF templates updated to read
yacht details from the new entity.
- `client-detail-header`, `client-picker`, `command-search`,
`search-result-item`, `use-search` hook, `types/domain.ts`,
`search.service` — drop the companyName badge / sub-label / typed
field everywhere it was rendered or fetched.
- `ai.ts` worker: drop the company / yacht context lines from the
prompt (will be re-added later sourced from the new entities).
- `validators/interests.ts`: remove the deprecated public-form flat
yacht/company fields. The route already ignores them.
- `factories.ts`: drop the `isProxy: false` default.
Tests: 652/652 green; type-check clean. The
`security-sensitive-data` tests use `companyName` / `isProxy` as
arbitrary record keys for a generic util — left unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:57:54 +02:00
|
|
|
conditions.push(ilike(clients.fullName, `%${search}%`));
|
Initial commit: Port Nimara CRM (Layers 0-4)
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return db
|
|
|
|
|
.select({ id: clients.id, fullName: clients.fullName })
|
|
|
|
|
.from(clients)
|
|
|
|
|
.where(and(...conditions))
|
|
|
|
|
.orderBy(clients.fullName)
|
|
|
|
|
.limit(50);
|
|
|
|
|
}
|