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>
651 lines
20 KiB
TypeScript
651 lines
20 KiB
TypeScript
/**
|
|
* Test factory helpers.
|
|
*
|
|
* Two flavours:
|
|
* 1. Async DB-inserting factories (makePort, makeClient, makeBerth, makeYacht,
|
|
* makeCompany, ...) — insert a row via the app's `db` handle and return the
|
|
* inserted record. Use these from integration tests that need real FK /
|
|
* unique-index enforcement. They require DATABASE_URL to be reachable.
|
|
* 2. Plain-data helpers (makeAuditMeta, makeCreateClientInput, makeCreate*)
|
|
* — return in-memory objects suitable for unit tests with mocked `db`.
|
|
*/
|
|
|
|
import { and, eq, sql } from 'drizzle-orm';
|
|
import { db } from '@/lib/db';
|
|
import { ports, type NewPort, type Port } from '@/lib/db/schema/ports';
|
|
import { clients, type NewClient, type Client } from '@/lib/db/schema/clients';
|
|
import { berths, type NewBerth, type Berth } from '@/lib/db/schema/berths';
|
|
import { yachts, yachtOwnershipHistory, type NewYacht, type Yacht } from '@/lib/db/schema/yachts';
|
|
import {
|
|
companies,
|
|
companyMemberships,
|
|
type NewCompany,
|
|
type Company,
|
|
} from '@/lib/db/schema/companies';
|
|
import { berthReservations, type BerthReservation } from '@/lib/db/schema/reservations';
|
|
|
|
// ─── Port ────────────────────────────────────────────────────────────────────
|
|
|
|
export async function makePort(args?: { overrides?: Partial<NewPort> }): Promise<Port> {
|
|
const suffix = Math.random().toString(36).slice(2, 10);
|
|
const [port] = await db
|
|
.insert(ports)
|
|
.values({
|
|
name: args?.overrides?.name ?? `Test Port ${suffix}`,
|
|
slug: args?.overrides?.slug ?? `test-port-${suffix}`,
|
|
...args?.overrides,
|
|
})
|
|
.returning();
|
|
return port!;
|
|
}
|
|
|
|
// ─── Client ──────────────────────────────────────────────────────────────────
|
|
|
|
export async function makeClient(args: {
|
|
portId: string;
|
|
overrides?: Partial<NewClient>;
|
|
}): Promise<Client> {
|
|
const [client] = await db
|
|
.insert(clients)
|
|
.values({
|
|
portId: args.portId,
|
|
fullName: args.overrides?.fullName ?? `Test Client ${Math.random().toString(36).slice(2, 8)}`,
|
|
...args.overrides,
|
|
})
|
|
.returning();
|
|
return client!;
|
|
}
|
|
|
|
// ─── Berth ────────────────────────────────────────────────────────────────────
|
|
|
|
export async function makeBerth(args: {
|
|
portId: string;
|
|
overrides?: Partial<NewBerth>;
|
|
}): Promise<Berth> {
|
|
const [berth] = await db
|
|
.insert(berths)
|
|
.values({
|
|
portId: args.portId,
|
|
mooringNumber: args.overrides?.mooringNumber ?? `B-${Math.random().toString(36).slice(2, 8)}`,
|
|
...args.overrides,
|
|
})
|
|
.returning();
|
|
return berth!;
|
|
}
|
|
|
|
// ─── Yacht ───────────────────────────────────────────────────────────────────
|
|
|
|
export async function makeYacht(args: {
|
|
portId: string;
|
|
ownerType: 'client' | 'company';
|
|
ownerId: string;
|
|
name?: string;
|
|
status?: 'active' | 'retired' | 'sold_away';
|
|
hullNumber?: string;
|
|
registration?: string;
|
|
overrides?: Partial<NewYacht>;
|
|
}): Promise<Yacht> {
|
|
const [yacht] = await db
|
|
.insert(yachts)
|
|
.values({
|
|
portId: args.portId,
|
|
name: args.name ?? args.overrides?.name ?? `Yacht ${Math.random().toString(36).slice(2, 8)}`,
|
|
currentOwnerType: args.ownerType,
|
|
currentOwnerId: args.ownerId,
|
|
...(args.status !== undefined ? { status: args.status } : {}),
|
|
...(args.hullNumber !== undefined ? { hullNumber: args.hullNumber } : {}),
|
|
...(args.registration !== undefined ? { registration: args.registration } : {}),
|
|
...args.overrides,
|
|
})
|
|
.returning();
|
|
await db.insert(yachtOwnershipHistory).values({
|
|
yachtId: yacht!.id,
|
|
ownerType: args.ownerType,
|
|
ownerId: args.ownerId,
|
|
startDate: new Date(),
|
|
endDate: null,
|
|
createdBy: 'test',
|
|
});
|
|
return yacht!;
|
|
}
|
|
|
|
// ─── Company ─────────────────────────────────────────────────────────────────
|
|
|
|
export async function makeCompany(args: {
|
|
portId: string;
|
|
overrides?: Partial<NewCompany>;
|
|
}): Promise<Company> {
|
|
const [company] = await db
|
|
.insert(companies)
|
|
.values({
|
|
portId: args.portId,
|
|
name: args.overrides?.name ?? `Company ${Math.random().toString(36).slice(2, 8)}`,
|
|
...args.overrides,
|
|
})
|
|
.returning();
|
|
return company!;
|
|
}
|
|
|
|
// ─── Company Membership ──────────────────────────────────────────────────────
|
|
|
|
export async function makeMembership(args: {
|
|
companyId: string;
|
|
clientId: string;
|
|
role?: string;
|
|
roleDetail?: string;
|
|
startDate?: Date;
|
|
endDate?: Date | null;
|
|
isPrimary?: boolean;
|
|
notes?: string;
|
|
}) {
|
|
const [row] = await db
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId: args.companyId,
|
|
clientId: args.clientId,
|
|
role: args.role ?? 'director',
|
|
roleDetail: args.roleDetail ?? null,
|
|
startDate: args.startDate ?? new Date(),
|
|
endDate: args.endDate ?? null,
|
|
isPrimary: args.isPrimary ?? false,
|
|
notes: args.notes ?? null,
|
|
})
|
|
.returning();
|
|
if (!row) throw new Error('Failed to create membership');
|
|
return row;
|
|
}
|
|
|
|
// ─── Berth Reservation ───────────────────────────────────────────────────────
|
|
|
|
export async function makeReservation(args: {
|
|
berthId: string;
|
|
portId: string;
|
|
clientId: string;
|
|
yachtId: string;
|
|
status: 'pending' | 'active' | 'ended' | 'cancelled';
|
|
startDate?: Date;
|
|
endDate?: Date | null;
|
|
tenureType?: 'permanent' | 'fixed_term' | 'seasonal';
|
|
interestId?: string;
|
|
createdBy?: string;
|
|
notes?: string;
|
|
}): Promise<BerthReservation> {
|
|
const [row] = await db
|
|
.insert(berthReservations)
|
|
.values({
|
|
berthId: args.berthId,
|
|
portId: args.portId,
|
|
clientId: args.clientId,
|
|
yachtId: args.yachtId,
|
|
interestId: args.interestId ?? null,
|
|
status: args.status,
|
|
startDate: args.startDate ?? new Date(),
|
|
endDate: args.endDate ?? null,
|
|
tenureType: args.tenureType ?? 'permanent',
|
|
contractFileId: null,
|
|
notes: args.notes ?? null,
|
|
createdBy: args.createdBy ?? 'seed-user',
|
|
})
|
|
.returning();
|
|
if (!row) throw new Error('Failed to create reservation');
|
|
return row;
|
|
}
|
|
|
|
// ─── Yacht Ownership Transfer ────────────────────────────────────────────────
|
|
|
|
export async function makeOwnershipTransfer(args: {
|
|
yachtId: string;
|
|
newOwner: { type: 'client' | 'company'; id: string };
|
|
effectiveDate?: Date;
|
|
transferReason?: string;
|
|
transferNotes?: string;
|
|
createdBy?: string;
|
|
}) {
|
|
const effective = args.effectiveDate ?? new Date();
|
|
const createdBy = args.createdBy ?? 'seed-user';
|
|
return await db.transaction(async (tx) => {
|
|
// Close current open row
|
|
await tx
|
|
.update(yachtOwnershipHistory)
|
|
.set({ endDate: effective })
|
|
.where(
|
|
and(
|
|
eq(yachtOwnershipHistory.yachtId, args.yachtId),
|
|
sql`${yachtOwnershipHistory.endDate} IS NULL`,
|
|
),
|
|
);
|
|
|
|
// Insert new open row
|
|
const [newHistory] = await tx
|
|
.insert(yachtOwnershipHistory)
|
|
.values({
|
|
yachtId: args.yachtId,
|
|
ownerType: args.newOwner.type,
|
|
ownerId: args.newOwner.id,
|
|
startDate: effective,
|
|
endDate: null,
|
|
transferReason: args.transferReason ?? null,
|
|
transferNotes: args.transferNotes ?? null,
|
|
createdBy,
|
|
})
|
|
.returning();
|
|
|
|
// Update yacht's denormalized current owner
|
|
const [updated] = await tx
|
|
.update(yachts)
|
|
.set({
|
|
currentOwnerType: args.newOwner.type,
|
|
currentOwnerId: args.newOwner.id,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(yachts.id, args.yachtId))
|
|
.returning();
|
|
|
|
return { history: newHistory!, yacht: updated! };
|
|
});
|
|
}
|
|
|
|
// ─── Webhook ──────────────────────────────────────────────────────────────────
|
|
|
|
export interface WebhookData {
|
|
id: string;
|
|
portId: string;
|
|
name: string;
|
|
url: string;
|
|
secret: string | null;
|
|
events: string[];
|
|
isActive: boolean;
|
|
createdBy: string;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
export function makeWebhook(overrides?: Partial<WebhookData>): WebhookData {
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
portId: crypto.randomUUID(),
|
|
name: 'Test Webhook',
|
|
url: 'https://example.com/webhook',
|
|
secret: null,
|
|
events: ['client.created'],
|
|
isActive: true,
|
|
createdBy: crypto.randomUUID(),
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ─── Audit Log ────────────────────────────────────────────────────────────────
|
|
|
|
export interface AuditMeta {
|
|
userId: string;
|
|
portId: string;
|
|
ipAddress: string;
|
|
userAgent: string;
|
|
}
|
|
|
|
export function makeAuditMeta(overrides?: Partial<AuditMeta>): AuditMeta {
|
|
return {
|
|
userId: crypto.randomUUID(),
|
|
portId: crypto.randomUUID(),
|
|
ipAddress: '127.0.0.1',
|
|
userAgent: 'vitest/1.0',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ─── Auth Context ─────────────────────────────────────────────────────────────
|
|
|
|
import type { RolePermissions } from '@/lib/db/schema/users';
|
|
|
|
/** Full permissions — every action allowed. */
|
|
export function makeFullPermissions(): RolePermissions {
|
|
return {
|
|
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
|
interests: {
|
|
view: true,
|
|
create: true,
|
|
edit: true,
|
|
delete: true,
|
|
change_stage: true,
|
|
generate_eoi: true,
|
|
export: true,
|
|
},
|
|
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
|
documents: {
|
|
view: true,
|
|
create: true,
|
|
send_for_signing: true,
|
|
upload_signed: true,
|
|
delete: true,
|
|
},
|
|
expenses: {
|
|
view: true,
|
|
create: true,
|
|
edit: true,
|
|
delete: true,
|
|
export: true,
|
|
scan_receipt: true,
|
|
},
|
|
invoices: {
|
|
view: true,
|
|
create: true,
|
|
edit: true,
|
|
delete: true,
|
|
send: true,
|
|
record_payment: true,
|
|
export: true,
|
|
},
|
|
files: { view: true, upload: true, delete: true, manage_folders: true },
|
|
email: { view: true, send: true, configure_account: true },
|
|
reminders: {
|
|
view_own: true,
|
|
view_all: true,
|
|
create: true,
|
|
edit_own: true,
|
|
edit_all: true,
|
|
assign_others: true,
|
|
},
|
|
calendar: { connect: true, view_events: true },
|
|
reports: { view_dashboard: true, view_analytics: true, export: true },
|
|
document_templates: { view: true, generate: true, manage: true },
|
|
yachts: { view: true, create: true, edit: true, delete: true, transfer: true },
|
|
companies: { view: true, create: true, edit: true, delete: true },
|
|
memberships: { view: true, manage: true },
|
|
reservations: { view: true, create: true, activate: true, cancel: true },
|
|
admin: {
|
|
manage_users: true,
|
|
view_audit_log: true,
|
|
manage_settings: true,
|
|
manage_webhooks: true,
|
|
manage_reports: true,
|
|
manage_custom_fields: true,
|
|
manage_forms: true,
|
|
manage_tags: true,
|
|
system_backup: true,
|
|
},
|
|
residential_clients: { view: true, create: true, edit: true, delete: true },
|
|
residential_interests: {
|
|
view: true,
|
|
create: true,
|
|
edit: true,
|
|
delete: true,
|
|
change_stage: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Read-only viewer permissions — no create/update/delete. */
|
|
export function makeViewerPermissions(): RolePermissions {
|
|
return {
|
|
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false },
|
|
interests: {
|
|
view: true,
|
|
create: false,
|
|
edit: false,
|
|
delete: false,
|
|
change_stage: false,
|
|
generate_eoi: false,
|
|
export: false,
|
|
},
|
|
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
|
documents: {
|
|
view: true,
|
|
create: false,
|
|
send_for_signing: false,
|
|
upload_signed: false,
|
|
delete: false,
|
|
},
|
|
expenses: {
|
|
view: true,
|
|
create: false,
|
|
edit: false,
|
|
delete: false,
|
|
export: false,
|
|
scan_receipt: false,
|
|
},
|
|
invoices: {
|
|
view: true,
|
|
create: false,
|
|
edit: false,
|
|
delete: false,
|
|
send: false,
|
|
record_payment: false,
|
|
export: false,
|
|
},
|
|
files: { view: true, upload: false, delete: false, manage_folders: false },
|
|
email: { view: true, send: false, configure_account: false },
|
|
reminders: {
|
|
view_own: true,
|
|
view_all: false,
|
|
create: false,
|
|
edit_own: false,
|
|
edit_all: false,
|
|
assign_others: false,
|
|
},
|
|
calendar: { connect: false, view_events: true },
|
|
reports: { view_dashboard: true, view_analytics: false, export: false },
|
|
document_templates: { view: true, generate: false, manage: false },
|
|
yachts: { view: true, create: false, edit: false, delete: false, transfer: false },
|
|
companies: { view: true, create: false, edit: false, delete: false },
|
|
memberships: { view: true, manage: false },
|
|
reservations: { view: true, create: false, activate: false, cancel: false },
|
|
admin: {
|
|
manage_users: false,
|
|
view_audit_log: false,
|
|
manage_settings: false,
|
|
manage_webhooks: false,
|
|
manage_reports: false,
|
|
manage_custom_fields: false,
|
|
manage_forms: false,
|
|
manage_tags: false,
|
|
system_backup: false,
|
|
},
|
|
residential_clients: { view: false, create: false, edit: false, delete: false },
|
|
residential_interests: {
|
|
view: false,
|
|
create: false,
|
|
edit: false,
|
|
delete: false,
|
|
change_stage: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Sales agent permissions — own clients/interests, no admin. */
|
|
export function makeSalesAgentPermissions(): RolePermissions {
|
|
return {
|
|
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: false },
|
|
interests: {
|
|
view: true,
|
|
create: true,
|
|
edit: true,
|
|
delete: false,
|
|
change_stage: true,
|
|
generate_eoi: true,
|
|
export: false,
|
|
},
|
|
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
|
documents: {
|
|
view: true,
|
|
create: true,
|
|
send_for_signing: true,
|
|
upload_signed: true,
|
|
delete: false,
|
|
},
|
|
expenses: {
|
|
view: true,
|
|
create: true,
|
|
edit: true,
|
|
delete: false,
|
|
export: false,
|
|
scan_receipt: true,
|
|
},
|
|
invoices: {
|
|
view: true,
|
|
create: false,
|
|
edit: false,
|
|
delete: false,
|
|
send: false,
|
|
record_payment: false,
|
|
export: false,
|
|
},
|
|
files: { view: true, upload: true, delete: false, manage_folders: false },
|
|
email: { view: true, send: true, configure_account: false },
|
|
reminders: {
|
|
view_own: true,
|
|
view_all: false,
|
|
create: true,
|
|
edit_own: true,
|
|
edit_all: false,
|
|
assign_others: false,
|
|
},
|
|
calendar: { connect: true, view_events: true },
|
|
reports: { view_dashboard: true, view_analytics: false, export: false },
|
|
document_templates: { view: true, generate: true, manage: false },
|
|
yachts: { view: true, create: true, edit: true, delete: false, transfer: false },
|
|
companies: { view: true, create: true, edit: false, delete: false },
|
|
memberships: { view: true, manage: false },
|
|
reservations: { view: true, create: true, activate: true, cancel: false },
|
|
admin: {
|
|
manage_users: false,
|
|
view_audit_log: false,
|
|
manage_settings: false,
|
|
manage_webhooks: false,
|
|
manage_reports: false,
|
|
manage_custom_fields: false,
|
|
manage_forms: false,
|
|
manage_tags: false,
|
|
system_backup: false,
|
|
},
|
|
residential_clients: { view: false, create: false, edit: false, delete: false },
|
|
residential_interests: {
|
|
view: false,
|
|
create: false,
|
|
edit: false,
|
|
delete: false,
|
|
change_stage: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Sales manager — can do most things, limited admin. */
|
|
export function makeSalesManagerPermissions(): RolePermissions {
|
|
return {
|
|
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true },
|
|
interests: {
|
|
view: true,
|
|
create: true,
|
|
edit: true,
|
|
delete: true,
|
|
change_stage: true,
|
|
generate_eoi: true,
|
|
export: true,
|
|
},
|
|
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
|
documents: {
|
|
view: true,
|
|
create: true,
|
|
send_for_signing: true,
|
|
upload_signed: true,
|
|
delete: true,
|
|
},
|
|
expenses: {
|
|
view: true,
|
|
create: true,
|
|
edit: true,
|
|
delete: true,
|
|
export: true,
|
|
scan_receipt: true,
|
|
},
|
|
invoices: {
|
|
view: true,
|
|
create: true,
|
|
edit: true,
|
|
delete: false,
|
|
send: true,
|
|
record_payment: true,
|
|
export: true,
|
|
},
|
|
files: { view: true, upload: true, delete: true, manage_folders: true },
|
|
email: { view: true, send: true, configure_account: false },
|
|
reminders: {
|
|
view_own: true,
|
|
view_all: true,
|
|
create: true,
|
|
edit_own: true,
|
|
edit_all: true,
|
|
assign_others: true,
|
|
},
|
|
calendar: { connect: true, view_events: true },
|
|
reports: { view_dashboard: true, view_analytics: true, export: true },
|
|
document_templates: { view: true, generate: true, manage: false },
|
|
yachts: { view: true, create: true, edit: true, delete: false, transfer: true },
|
|
companies: { view: true, create: true, edit: true, delete: false },
|
|
memberships: { view: true, manage: true },
|
|
reservations: { view: true, create: true, activate: true, cancel: true },
|
|
admin: {
|
|
manage_users: false,
|
|
view_audit_log: true,
|
|
manage_settings: false,
|
|
manage_webhooks: false,
|
|
manage_reports: true,
|
|
manage_custom_fields: false,
|
|
manage_forms: false,
|
|
manage_tags: true,
|
|
system_backup: false,
|
|
},
|
|
residential_clients: { view: true, create: true, edit: true, delete: true },
|
|
residential_interests: {
|
|
view: true,
|
|
create: true,
|
|
edit: true,
|
|
delete: true,
|
|
change_stage: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Director — everything except system backup. */
|
|
export function makeDirectorPermissions(): RolePermissions {
|
|
return {
|
|
...makeFullPermissions(),
|
|
admin: {
|
|
...makeFullPermissions().admin,
|
|
system_backup: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── Minimal valid CreateClientInput ─────────────────────────────────────────
|
|
/** Returns a minimal valid CreateClientInput object for use in service calls. */
|
|
export function makeCreateClientInput(overrides?: { fullName?: string; portId?: string }) {
|
|
return {
|
|
fullName: overrides?.fullName ?? 'Test Client',
|
|
contacts: [{ channel: 'email' as const, value: 'test@example.com', isPrimary: true }],
|
|
tagIds: [] as string[],
|
|
};
|
|
}
|
|
|
|
/** Returns a minimal valid CreateInterestInput object. */
|
|
export function makeCreateInterestInput(overrides?: {
|
|
clientId?: string;
|
|
pipelineStage?:
|
|
| 'open'
|
|
| 'details_sent'
|
|
| 'in_communication'
|
|
| 'visited'
|
|
| 'signed_eoi_nda'
|
|
| 'deposit_10pct'
|
|
| 'contract'
|
|
| 'completed';
|
|
}) {
|
|
return {
|
|
clientId: overrides?.clientId ?? crypto.randomUUID(),
|
|
pipelineStage: overrides?.pipelineStage ?? ('open' as const),
|
|
reminderEnabled: false,
|
|
tagIds: [] as string[],
|
|
};
|
|
}
|