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>
This commit is contained in:
328
src/lib/services/residential.service.ts
Normal file
328
src/lib/services/residential.service.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
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';
|
||||
|
||||
interface AuditMeta {
|
||||
userId: string;
|
||||
portId: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
// ─── 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();
|
||||
if (!row) throw new Error('Failed to create residential client');
|
||||
|
||||
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');
|
||||
|
||||
const client = await db.query.residentialClients.findFirst({
|
||||
where: eq(residentialClients.id, interest.residentialClientId),
|
||||
});
|
||||
|
||||
return { ...interest, client };
|
||||
}
|
||||
|
||||
export async function createResidentialInterest(
|
||||
portId: string,
|
||||
data: CreateResidentialInterestInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
// Validate the residential client belongs to this port — prevents
|
||||
// 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();
|
||||
if (!row) throw new Error('Failed to create residential interest');
|
||||
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user