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, eq } from 'drizzle-orm';
|
|
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
|
|
|
|
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
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
|
|
|
import { CodedError, 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 { emitToRoom } from '@/lib/socket/server';
|
|
|
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
|
|
|
import { diffEntity } from '@/lib/entity-diff';
|
|
|
|
|
import { softDelete, restore } from '@/lib/db/utils';
|
|
|
|
|
import type {
|
|
|
|
|
CreateResidentialClientInput,
|
|
|
|
|
CreateResidentialInterestInput,
|
|
|
|
|
ListResidentialClientsInput,
|
|
|
|
|
ListResidentialInterestsInput,
|
|
|
|
|
UpdateResidentialClientInput,
|
|
|
|
|
UpdateResidentialInterestInput,
|
|
|
|
|
} from '@/lib/validators/residential';
|
|
|
|
|
|
|
|
|
|
// ─── Residential clients ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listResidentialClients(portId: string, query: ListResidentialClientsInput) {
|
|
|
|
|
const { page, limit, sort, order, search, includeArchived, status, source } = query;
|
|
|
|
|
|
|
|
|
|
const filters = [];
|
|
|
|
|
if (status) filters.push(eq(residentialClients.status, status));
|
|
|
|
|
if (source) filters.push(eq(residentialClients.source, source));
|
|
|
|
|
|
|
|
|
|
return buildListQuery({
|
|
|
|
|
table: residentialClients,
|
|
|
|
|
portIdColumn: residentialClients.portId,
|
|
|
|
|
portId,
|
|
|
|
|
idColumn: residentialClients.id,
|
|
|
|
|
updatedAtColumn: residentialClients.updatedAt,
|
|
|
|
|
filters,
|
|
|
|
|
sort: sort
|
|
|
|
|
? {
|
|
|
|
|
column:
|
|
|
|
|
(residentialClients[sort as keyof typeof residentialClients] as never) ??
|
|
|
|
|
residentialClients.updatedAt,
|
|
|
|
|
direction: order ?? 'desc',
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
page,
|
|
|
|
|
pageSize: limit,
|
|
|
|
|
searchColumns: [
|
|
|
|
|
residentialClients.fullName,
|
|
|
|
|
residentialClients.email,
|
|
|
|
|
residentialClients.phone,
|
|
|
|
|
residentialClients.placeOfResidence,
|
|
|
|
|
],
|
|
|
|
|
searchTerm: search,
|
|
|
|
|
includeArchived,
|
|
|
|
|
archivedAtColumn: residentialClients.archivedAt,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getResidentialClientById(id: string, portId: string) {
|
|
|
|
|
const client = await db.query.residentialClients.findFirst({
|
|
|
|
|
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!client) throw new NotFoundError('Residential client');
|
|
|
|
|
|
|
|
|
|
const interests = await db.query.residentialInterests.findMany({
|
|
|
|
|
where: eq(residentialInterests.residentialClientId, id),
|
|
|
|
|
orderBy: (t, { desc }) => [desc(t.updatedAt)],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { ...client, interests };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createResidentialClient(
|
|
|
|
|
portId: string,
|
|
|
|
|
data: CreateResidentialClientInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const [row] = await db
|
|
|
|
|
.insert(residentialClients)
|
|
|
|
|
.values({ portId, ...data })
|
|
|
|
|
.returning();
|
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
|
|
|
if (!row)
|
|
|
|
|
throw new CodedError('INSERT_RETURNING_EMPTY', {
|
|
|
|
|
internalMessage: 'Failed to create residential client',
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'residential_client',
|
|
|
|
|
entityId: row.id,
|
|
|
|
|
newValue: { fullName: row.fullName, email: row.email ?? undefined },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
emitToRoom(`port:${portId}`, 'residential_client:created', { id: row.id });
|
|
|
|
|
|
|
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateResidentialClient(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: UpdateResidentialClientInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const before = await db.query.residentialClients.findFirst({
|
|
|
|
|
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!before) throw new NotFoundError('Residential client');
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(residentialClients)
|
|
|
|
|
.set({ ...data, updatedAt: new Date() })
|
|
|
|
|
.where(and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
if (!updated) throw new NotFoundError('Residential client');
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'residential_client',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: diffEntity(before, updated) as Record<string, unknown>,
|
|
|
|
|
newValue: data as Record<string, unknown>,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
emitToRoom(`port:${portId}`, 'residential_client:updated', { id });
|
|
|
|
|
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function archiveResidentialClient(id: string, portId: string, meta: AuditMeta) {
|
|
|
|
|
const existing = await db.query.residentialClients.findFirst({
|
|
|
|
|
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Residential client');
|
|
|
|
|
|
|
|
|
|
await softDelete(residentialClients, residentialClients.id, id);
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'archive',
|
|
|
|
|
entityType: 'residential_client',
|
|
|
|
|
entityId: id,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
emitToRoom(`port:${portId}`, 'residential_client:archived', { id });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function restoreResidentialClient(id: string, portId: string, meta: AuditMeta) {
|
|
|
|
|
const existing = await db.query.residentialClients.findFirst({
|
|
|
|
|
where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Residential client');
|
|
|
|
|
|
|
|
|
|
await restore(residentialClients, residentialClients.id, id);
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'restore',
|
|
|
|
|
entityType: 'residential_client',
|
|
|
|
|
entityId: id,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
emitToRoom(`port:${portId}`, 'residential_client:restored', { id });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Residential interests ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export async function listResidentialInterests(
|
|
|
|
|
portId: string,
|
|
|
|
|
query: ListResidentialInterestsInput,
|
|
|
|
|
) {
|
|
|
|
|
const {
|
|
|
|
|
page,
|
|
|
|
|
limit,
|
|
|
|
|
sort,
|
|
|
|
|
order,
|
|
|
|
|
search,
|
|
|
|
|
includeArchived,
|
|
|
|
|
pipelineStage,
|
|
|
|
|
assignedTo,
|
|
|
|
|
residentialClientId,
|
|
|
|
|
} = query;
|
|
|
|
|
|
|
|
|
|
const filters = [];
|
|
|
|
|
if (pipelineStage) filters.push(eq(residentialInterests.pipelineStage, pipelineStage));
|
|
|
|
|
if (assignedTo) filters.push(eq(residentialInterests.assignedTo, assignedTo));
|
|
|
|
|
if (residentialClientId)
|
|
|
|
|
filters.push(eq(residentialInterests.residentialClientId, residentialClientId));
|
|
|
|
|
|
|
|
|
|
return buildListQuery({
|
|
|
|
|
table: residentialInterests,
|
|
|
|
|
portIdColumn: residentialInterests.portId,
|
|
|
|
|
portId,
|
|
|
|
|
idColumn: residentialInterests.id,
|
|
|
|
|
updatedAtColumn: residentialInterests.updatedAt,
|
|
|
|
|
filters,
|
|
|
|
|
sort: sort
|
|
|
|
|
? {
|
|
|
|
|
column:
|
|
|
|
|
(residentialInterests[sort as keyof typeof residentialInterests] as never) ??
|
|
|
|
|
residentialInterests.updatedAt,
|
|
|
|
|
direction: order ?? 'desc',
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
page,
|
|
|
|
|
pageSize: limit,
|
|
|
|
|
searchColumns: [residentialInterests.notes, residentialInterests.preferences],
|
|
|
|
|
searchTerm: search,
|
|
|
|
|
includeArchived,
|
|
|
|
|
archivedAtColumn: residentialInterests.archivedAt,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getResidentialInterestById(id: string, portId: string) {
|
|
|
|
|
const interest = await db.query.residentialInterests.findFirst({
|
|
|
|
|
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!interest) throw new NotFoundError('Residential interest');
|
|
|
|
|
|
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
|
|
|
// The residentialInterest is already port-scoped; pin the client read
|
|
|
|
|
// to the same port too so a future drift (a foreign-port residential
|
|
|
|
|
// client id ever landing on the interest) cannot leak.
|
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 client = await db.query.residentialClients.findFirst({
|
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
|
|
|
where: and(
|
|
|
|
|
eq(residentialClients.id, interest.residentialClientId),
|
|
|
|
|
eq(residentialClients.portId, portId),
|
|
|
|
|
),
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { ...interest, client };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createResidentialInterest(
|
|
|
|
|
portId: string,
|
|
|
|
|
data: CreateResidentialInterestInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
2026-05-04 22:57:01 +02:00
|
|
|
// Validate the residential client belongs to this port - prevents
|
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
|
|
|
// cross-port linking.
|
|
|
|
|
const client = await db.query.residentialClients.findFirst({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(residentialClients.id, data.residentialClientId),
|
|
|
|
|
eq(residentialClients.portId, portId),
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
if (!client) throw new NotFoundError('Residential client');
|
|
|
|
|
|
|
|
|
|
const [row] = await db
|
|
|
|
|
.insert(residentialInterests)
|
|
|
|
|
.values({ portId, ...data })
|
|
|
|
|
.returning();
|
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
|
|
|
if (!row)
|
|
|
|
|
throw new CodedError('INSERT_RETURNING_EMPTY', {
|
|
|
|
|
internalMessage: 'Failed to create residential interest',
|
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'create',
|
|
|
|
|
entityType: 'residential_interest',
|
|
|
|
|
entityId: row.id,
|
|
|
|
|
newValue: { residentialClientId: row.residentialClientId, pipelineStage: row.pipelineStage },
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: row.id });
|
|
|
|
|
|
|
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateResidentialInterest(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: UpdateResidentialInterestInput,
|
|
|
|
|
meta: AuditMeta,
|
|
|
|
|
) {
|
|
|
|
|
const before = await db.query.residentialInterests.findFirst({
|
|
|
|
|
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!before) throw new NotFoundError('Residential interest');
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(residentialInterests)
|
|
|
|
|
.set({ ...data, updatedAt: new Date() })
|
|
|
|
|
.where(and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)))
|
|
|
|
|
.returning();
|
|
|
|
|
if (!updated) throw new NotFoundError('Residential interest');
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'update',
|
|
|
|
|
entityType: 'residential_interest',
|
|
|
|
|
entityId: id,
|
|
|
|
|
oldValue: diffEntity(before, updated) as Record<string, unknown>,
|
|
|
|
|
newValue: data as Record<string, unknown>,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
emitToRoom(`port:${portId}`, 'residential_interest:updated', { id });
|
|
|
|
|
|
|
|
|
|
return updated;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function archiveResidentialInterest(id: string, portId: string, meta: AuditMeta) {
|
|
|
|
|
const existing = await db.query.residentialInterests.findFirst({
|
|
|
|
|
where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!existing) throw new NotFoundError('Residential interest');
|
|
|
|
|
|
|
|
|
|
await softDelete(residentialInterests, residentialInterests.id, id);
|
|
|
|
|
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: meta.userId,
|
|
|
|
|
portId,
|
|
|
|
|
action: 'archive',
|
|
|
|
|
entityType: 'residential_interest',
|
|
|
|
|
entityId: id,
|
|
|
|
|
ipAddress: meta.ipAddress,
|
|
|
|
|
userAgent: meta.userAgent,
|
|
|
|
|
});
|
|
|
|
|
emitToRoom(`port:${portId}`, 'residential_interest:archived', { id });
|
|
|
|
|
}
|