feat(seed): synthetic fixture covering every pipeline stage + db:reset
Splits seed bootstrap (ports/roles/profile) into a shared module so
two seed entry points can share it:
- pnpm db:seed realistic NocoDB-shaped fixture (existing)
- pnpm db:seed:synthetic 12 clients, one per pipeline stage + archive
variants (rich metadata for restore wizard)
scripts/db-reset.ts truncates all data tables (preserves migrations);
guarded by --confirm and a localhost host check. Companion npm scripts:
- pnpm db:reset
- pnpm db:reseed:realistic
- pnpm db:reseed:synthetic
scripts/dev-open-browser.ts launches a headed Chromium with no viewport
override (uses the host monitor's natural size), pre-fills the login
form for the requested role.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,11 @@
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/lib/db/seed.ts",
|
||||
"db:seed:realistic": "tsx src/lib/db/seed.ts",
|
||||
"db:seed:synthetic": "tsx src/lib/db/seed-synthetic.ts",
|
||||
"db:reset": "tsx scripts/db-reset.ts --confirm",
|
||||
"db:reseed:realistic": "pnpm db:reset && pnpm db:seed:realistic",
|
||||
"db:reseed:synthetic": "pnpm db:reset && pnpm db:seed:synthetic",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:smoke": "playwright test --project=smoke",
|
||||
"test:e2e:exhaustive": "playwright test --project=exhaustive",
|
||||
|
||||
97
scripts/db-reset.ts
Normal file
97
scripts/db-reset.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Wipe all data from the database, preserving schema + drizzle migration
|
||||
* history. Run before swapping seed fixtures.
|
||||
*
|
||||
* pnpm tsx scripts/db-reset.ts (refuses without --confirm)
|
||||
* pnpm tsx scripts/db-reset.ts --confirm
|
||||
*
|
||||
* Truncates every table in the `public` schema except the drizzle
|
||||
* migration tracker, then resets sequences. Wraps the loop in a single
|
||||
* transaction so a mid-wipe failure rolls back cleanly.
|
||||
*
|
||||
* Refuses to run when DATABASE_URL points at anything that doesn't look
|
||||
* like a local/dev host. Override with --i-know-what-im-doing.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error('DATABASE_URL is not set; aborting.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
if (!args.has('--confirm')) {
|
||||
console.error('Refusing to wipe without --confirm');
|
||||
console.error('Run again as: pnpm tsx scripts/db-reset.ts --confirm');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Best-effort safety: refuse for anything that doesn't look like a local DB.
|
||||
function looksLocal(u: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return (
|
||||
parsed.hostname === 'localhost' ||
|
||||
parsed.hostname === '127.0.0.1' ||
|
||||
parsed.hostname === '::1' ||
|
||||
parsed.hostname.endsWith('.local') ||
|
||||
parsed.hostname.endsWith('.internal') ||
|
||||
parsed.hostname === 'host.docker.internal' ||
|
||||
// Docker compose service names commonly used here
|
||||
parsed.hostname === 'postgres' ||
|
||||
parsed.hostname === 'db'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!looksLocal(url) && !args.has('--i-know-what-im-doing')) {
|
||||
console.error(
|
||||
`DATABASE_URL host doesn't look local. Refusing to wipe a remote DB without --i-know-what-im-doing.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
async function main() {
|
||||
console.log('Resetting database...');
|
||||
console.log(` url: ${url.replace(/:[^:@]*@/, ':***@')}`);
|
||||
|
||||
const tables = await sql<{ tablename: string }[]>`
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename NOT LIKE 'drizzle_%'
|
||||
AND tablename != '__drizzle_migrations'
|
||||
`;
|
||||
|
||||
if (tables.length === 0) {
|
||||
console.log(' no user tables found, nothing to do.');
|
||||
await sql.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Single TRUNCATE … CASCADE is faster than per-table loops and handles
|
||||
// FK ordering for us. Quote table names defensively.
|
||||
const tableList = tables.map((t) => `"public"."${t.tablename}"`).join(', ');
|
||||
|
||||
console.log(` truncating ${tables.length} tables...`);
|
||||
await sql.unsafe(`TRUNCATE ${tableList} RESTART IDENTITY CASCADE`);
|
||||
console.log(' done.');
|
||||
|
||||
await sql.end();
|
||||
console.log('');
|
||||
console.log('Database reset complete. Run a seed script next:');
|
||||
console.log(' pnpm db:seed # realistic NocoDB-shaped fixture');
|
||||
console.log(' pnpm db:seed:synthetic # one client per pipeline stage');
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Reset failed:', err);
|
||||
await sql.end().catch(() => undefined);
|
||||
process.exit(1);
|
||||
});
|
||||
80
scripts/dev-open-browser.ts
Normal file
80
scripts/dev-open-browser.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Launch a headed Chromium with NO viewport override so it adopts the
|
||||
* host monitor's natural size — useful when you want to drive the CRM
|
||||
* manually and have full-screen real estate.
|
||||
*
|
||||
* Pre-fills the login form for the synthetic admin (admin@portnimara.test
|
||||
* / SuperAdmin12345!) but does not submit; press Enter when ready.
|
||||
*
|
||||
* The script keeps running until the browser window is closed by the
|
||||
* user or until you Ctrl-C.
|
||||
*
|
||||
* pnpm tsx scripts/dev-open-browser.ts # super_admin
|
||||
* pnpm tsx scripts/dev-open-browser.ts sales_agent
|
||||
* pnpm tsx scripts/dev-open-browser.ts viewer
|
||||
* pnpm tsx scripts/dev-open-browser.ts --no-prefill
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const USERS: Record<string, { email: string; password: string }> = {
|
||||
super_admin: { email: 'admin@portnimara.test', password: 'SuperAdmin12345!' },
|
||||
sales_agent: { email: 'agent@portnimara.test', password: 'SalesAgent12345!' },
|
||||
viewer: { email: 'viewer@portnimara.test', password: 'ViewerUser12345!' },
|
||||
};
|
||||
|
||||
const BASE_URL = process.env.DEV_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const noPrefill = args.includes('--no-prefill');
|
||||
const role =
|
||||
args.find((a) => !a.startsWith('--')) && USERS[args.find((a) => !a.startsWith('--'))!]
|
||||
? args.find((a) => !a.startsWith('--'))!
|
||||
: 'super_admin';
|
||||
const user = USERS[role]!;
|
||||
|
||||
console.log(`Launching headed Chromium → ${BASE_URL}`);
|
||||
console.log(` role: ${role} (${user.email})`);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
args: ['--start-maximized'],
|
||||
});
|
||||
|
||||
// viewport: null lets the page fill the OS window. Combined with
|
||||
// --start-maximized this matches the host monitor's natural size.
|
||||
const context = await browser.newContext({ viewport: null });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
|
||||
if (!noPrefill) {
|
||||
try {
|
||||
await page.waitForSelector('#email', { timeout: 5000 });
|
||||
await page.fill('#email', user.email);
|
||||
await page.fill('#password', user.password);
|
||||
console.log(' Login form pre-filled — press Enter in the browser to submit.');
|
||||
} catch {
|
||||
console.log(' Could not find login form (page may have redirected).');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log("Browser is open. Close it when you're done; the script will exit.");
|
||||
console.log('Or Ctrl-C here to force-quit.');
|
||||
|
||||
// Keep the process alive until the browser window is closed.
|
||||
await new Promise<void>((resolve) => {
|
||||
browser.on('disconnected', () => resolve());
|
||||
});
|
||||
|
||||
await browser.close().catch(() => undefined);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Open-browser failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
167
src/lib/db/seed-bootstrap.ts
Normal file
167
src/lib/db/seed-bootstrap.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Shared seed bootstrap: ports + system roles + super admin profile.
|
||||
*
|
||||
* Both the realistic seed (`seed.ts`) and the synthetic seed
|
||||
* (`seed-synthetic.ts`) call into this so we don't drift on the
|
||||
* permission maps or the operator account ids.
|
||||
*
|
||||
* Idempotent. Returns the resolved port ids so callers can chain
|
||||
* per-port fixture builders.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { ports } from './schema/ports';
|
||||
import { roles, userProfiles } from './schema/users';
|
||||
import {
|
||||
ALL_PERMISSIONS,
|
||||
DIRECTOR_PERMISSIONS,
|
||||
SALES_MANAGER_PERMISSIONS,
|
||||
SALES_AGENT_PERMISSIONS,
|
||||
VIEWER_PERMISSIONS,
|
||||
RESIDENTIAL_PARTNER_PERMISSIONS,
|
||||
} from './seed-permissions';
|
||||
|
||||
export interface BootstrappedPort {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export const PORT_DEFINITIONS: Array<{
|
||||
name: string;
|
||||
slug: string;
|
||||
primaryColor: string;
|
||||
defaultCurrency: string;
|
||||
timezone: string;
|
||||
}> = [
|
||||
{
|
||||
name: 'Port Nimara',
|
||||
slug: 'port-nimara',
|
||||
primaryColor: '#0F4C81',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Anguilla',
|
||||
},
|
||||
{
|
||||
name: 'Port Amador',
|
||||
slug: 'port-amador',
|
||||
primaryColor: '#D97706',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Panama',
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPER_ADMIN_USER_ID = 'super-admin-matt-portnimara';
|
||||
|
||||
export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
||||
console.log('Bootstrap: ports + roles + super admin profile');
|
||||
|
||||
// ── Ports ──────────────────────────────────────────────────────────────────
|
||||
const portIds: BootstrappedPort[] = [];
|
||||
for (const def of PORT_DEFINITIONS) {
|
||||
const [inserted] = await db
|
||||
.insert(ports)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: def.name,
|
||||
slug: def.slug,
|
||||
logoUrl: null,
|
||||
primaryColor: def.primaryColor,
|
||||
defaultCurrency: def.defaultCurrency,
|
||||
timezone: def.timezone,
|
||||
settings: {},
|
||||
isActive: true,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (inserted) {
|
||||
console.log(` Port created: ${def.name} (${inserted.id})`);
|
||||
portIds.push({ id: inserted.id, name: def.name, slug: def.slug });
|
||||
} else {
|
||||
const [existing] = await db.select().from(ports).where(eq(ports.slug, def.slug)).limit(1);
|
||||
if (existing) {
|
||||
console.log(` Port exists: ${def.name} (${existing.id})`);
|
||||
portIds.push({ id: existing.id, name: def.name, slug: def.slug });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── System roles ──────────────────────────────────────────────────────────
|
||||
const systemRoles = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'super_admin',
|
||||
description: 'Full system access. Bypasses all permission checks.',
|
||||
permissions: ALL_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'director',
|
||||
description: 'Operational admin within assigned port(s). Can manage users and settings.',
|
||||
permissions: DIRECTOR_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'sales_manager',
|
||||
description: 'Full sales access. Can view all reminders, assign tasks, and export reports.',
|
||||
permissions: SALES_MANAGER_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'sales_agent',
|
||||
description:
|
||||
'Standard sales role. View/create/edit clients and interests, manage own reminders.',
|
||||
permissions: SALES_AGENT_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'viewer',
|
||||
description: 'Read-only access to all records.',
|
||||
permissions: VIEWER_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'residential_partner',
|
||||
description:
|
||||
'External partner who handles residential inquiries. Sees only the residential pages — no marina clients, yachts, berths, or financial data.',
|
||||
permissions: RESIDENTIAL_PARTNER_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const role of systemRoles) {
|
||||
await db.insert(roles).values(role).onConflictDoNothing();
|
||||
}
|
||||
console.log(` Roles ensured: ${systemRoles.map((r) => r.name).join(', ')}`);
|
||||
|
||||
// ── Super admin profile placeholder ───────────────────────────────────────
|
||||
await db
|
||||
.insert(userProfiles)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: SUPER_ADMIN_USER_ID,
|
||||
displayName: 'Matt',
|
||||
avatarUrl: null,
|
||||
phone: null,
|
||||
isSuperAdmin: true,
|
||||
isActive: true,
|
||||
lastLoginAt: null,
|
||||
preferences: {},
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
console.log(` Super admin profile ensured (user_id: ${SUPER_ADMIN_USER_ID})`);
|
||||
|
||||
return portIds;
|
||||
}
|
||||
477
src/lib/db/seed-permissions.ts
Normal file
477
src/lib/db/seed-permissions.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Seed-time permission maps for the six system roles.
|
||||
*
|
||||
* Kept in their own module so both `seed.ts` (realistic) and
|
||||
* `seed-synthetic.ts` can share them without drift, and so the
|
||||
* giant role/permission grids don't pollute the seed orchestrator.
|
||||
*
|
||||
* Keep in sync with `src/lib/db/schema/users.ts → RolePermissions`
|
||||
* and `src/components/admin/roles/role-form.tsx → DEFAULT_PERMISSIONS`.
|
||||
*/
|
||||
|
||||
import type { RolePermissions } from './schema/users';
|
||||
|
||||
export const ALL_PERMISSIONS: RolePermissions = {
|
||||
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,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: 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, edit: 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,
|
||||
permanently_delete_clients: true,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
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,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: 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, edit: 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: false,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true },
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
send_for_signing: true,
|
||||
upload_signed: true,
|
||||
delete: false,
|
||||
},
|
||||
expenses: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
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, edit: true, delete: false, 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: 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: false,
|
||||
manage_settings: false,
|
||||
manage_webhooks: false,
|
||||
manage_reports: false,
|
||||
manage_custom_fields: false,
|
||||
manage_forms: false,
|
||||
manage_tags: true,
|
||||
system_backup: false,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true },
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
send_for_signing: true,
|
||||
upload_signed: true,
|
||||
delete: false,
|
||||
},
|
||||
expenses: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
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, edit: false, delete: false, manage_folders: false },
|
||||
email: { view: true, send: true, configure_account: true },
|
||||
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: true, export: true },
|
||||
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: true,
|
||||
system_backup: false,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
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,
|
||||
override_stage: false,
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
||||
documents: {
|
||||
view: true,
|
||||
create: false,
|
||||
edit: 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, edit: 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,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Residential Partner — for an outside party who handles residential
|
||||
// inquiries on the marina's behalf. Sees only the residential pages and
|
||||
// nothing else; can't see marina clients, yachts, berths, EOIs, etc.
|
||||
export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
||||
interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
override_stage: false,
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
send_for_signing: false,
|
||||
upload_signed: false,
|
||||
delete: false,
|
||||
},
|
||||
expenses: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
export: false,
|
||||
scan_receipt: false,
|
||||
},
|
||||
invoices: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
send: false,
|
||||
record_payment: false,
|
||||
export: false,
|
||||
},
|
||||
files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
|
||||
email: { view: false, send: false, configure_account: false },
|
||||
reminders: {
|
||||
view_own: true,
|
||||
view_all: false,
|
||||
create: true,
|
||||
edit_own: true,
|
||||
edit_all: false,
|
||||
assign_others: false,
|
||||
},
|
||||
calendar: { connect: false, view_events: false },
|
||||
reports: { view_dashboard: false, view_analytics: false, export: false },
|
||||
document_templates: { view: false, generate: false, manage: false },
|
||||
yachts: { view: false, create: false, edit: false, delete: false, transfer: false },
|
||||
companies: { view: false, create: false, edit: false, delete: false },
|
||||
memberships: { view: false, manage: false },
|
||||
reservations: { view: false, 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,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: false },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
764
src/lib/db/seed-synthetic-data.ts
Normal file
764
src/lib/db/seed-synthetic-data.ts
Normal file
@@ -0,0 +1,764 @@
|
||||
/**
|
||||
* Per-port synthetic seed builder for "every pipeline stage" coverage.
|
||||
*
|
||||
* The realistic seed in `seed-data.ts` mirrors the legacy NocoDB shape;
|
||||
* this one is purpose-built for thoroughly exercising the CRM. Every
|
||||
* pipeline stage gets at least one client, plus a handful of edge-case
|
||||
* fixtures (multi-interest, signed-EOI, archived with metadata, hard-
|
||||
* delete-eligible, company member, yacht owner).
|
||||
*
|
||||
* Berths come from the same NocoDB snapshot so the public berth API
|
||||
* still has data; the synthetic clients link to specific moorings so
|
||||
* the under_offer / sold derivations are deterministic.
|
||||
*
|
||||
* Idempotent: skips if the port already has clients seeded.
|
||||
*
|
||||
* Run via `pnpm db:seed:synthetic`.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from './index';
|
||||
import { withTransaction } from './utils';
|
||||
import {
|
||||
clients,
|
||||
clientContacts,
|
||||
clientAddresses,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companyAddresses,
|
||||
yachts,
|
||||
yachtOwnershipHistory,
|
||||
berths,
|
||||
berthReservations,
|
||||
interests,
|
||||
interestBerths,
|
||||
} from './schema';
|
||||
import { residentialClients, residentialInterests } from './schema';
|
||||
import { SUPER_ADMIN_USER_ID } from './seed-bootstrap';
|
||||
import berthSnapshot from './seed-data/berths.json';
|
||||
import type { PipelineStage } from '@/lib/constants';
|
||||
import type { ArchiveMetadata } from '@/lib/services/client-archive.service';
|
||||
|
||||
type SeedBerth = {
|
||||
legacyId: number;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
status: 'available' | 'under_offer' | 'sold';
|
||||
lengthFt: number | null;
|
||||
widthFt: number | null;
|
||||
draftFt: number | null;
|
||||
lengthM: number | null;
|
||||
widthM: number | null;
|
||||
draftM: number | null;
|
||||
widthIsMinimum: boolean;
|
||||
nominalBoatSize: number | null;
|
||||
nominalBoatSizeM: number | null;
|
||||
waterDepth: number | null;
|
||||
waterDepthM: number | null;
|
||||
waterDepthIsMinimum: boolean;
|
||||
sidePontoon: string | null;
|
||||
powerCapacity: number | null;
|
||||
voltage: number | null;
|
||||
mooringType: string | null;
|
||||
cleatType: string | null;
|
||||
cleatCapacity: string | null;
|
||||
bollardType: string | null;
|
||||
bollardCapacity: string | null;
|
||||
access: string | null;
|
||||
price: number | null;
|
||||
bowFacing: string | null;
|
||||
berthApproved: boolean;
|
||||
statusOverrideMode: string | null;
|
||||
};
|
||||
const BERTH_SNAPSHOT = berthSnapshot as SeedBerth[];
|
||||
|
||||
function daysAgo(n: number): Date {
|
||||
return new Date(Date.now() - n * 86_400_000);
|
||||
}
|
||||
|
||||
export interface SyntheticSeedSummary {
|
||||
berths: number;
|
||||
clients: number;
|
||||
interests: number;
|
||||
companies: number;
|
||||
yachts: number;
|
||||
residentialClients: number;
|
||||
}
|
||||
|
||||
interface SyntheticClientSpec {
|
||||
/** Used as a name suffix so test selectors can target it deterministically. */
|
||||
tag: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
countryIso: string;
|
||||
city: string;
|
||||
street: string;
|
||||
postalCode: string;
|
||||
/** Pipeline stage of the (single) interest. Omit for archived-only clients. */
|
||||
stage?: PipelineStage;
|
||||
/** Index into BERTH_SNAPSHOT for the primary linked berth. */
|
||||
berthIdx?: number;
|
||||
/** Mark interest as won/lost when stage = completed. */
|
||||
outcome?: 'won' | 'lost_unqualified' | 'lost_no_response';
|
||||
/** Archive the CLIENT after creation. When 'rich', fabricate
|
||||
* archive_metadata so the smart-restore wizard surfaces reversals. */
|
||||
archive?: 'simple' | 'rich';
|
||||
}
|
||||
|
||||
/**
|
||||
* Each spec produces exactly one client + one interest at the given
|
||||
* stage. Clients are tagged so a Playwright test can locate them by
|
||||
* either name (full name) or tag (substring after the dash).
|
||||
*
|
||||
* Berth indices map deterministically into the NocoDB snapshot which is
|
||||
* pre-sorted: idx 0..4 available, 5..9 under_offer, 10..11 sold.
|
||||
*/
|
||||
const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
{
|
||||
tag: 'open',
|
||||
fullName: 'Olivia Open — open',
|
||||
email: 'olivia.open@test.local',
|
||||
phone: '+1 555 010 0001',
|
||||
countryIso: 'GB',
|
||||
city: 'London',
|
||||
street: '1 Open Lane',
|
||||
postalCode: 'OP1 1OP',
|
||||
stage: 'open',
|
||||
// Open stage: no berth link yet
|
||||
},
|
||||
{
|
||||
tag: 'details',
|
||||
fullName: 'Daniel Details — details_sent',
|
||||
email: 'daniel.details@test.local',
|
||||
phone: '+1 555 010 0002',
|
||||
countryIso: 'US',
|
||||
city: 'Miami',
|
||||
street: '2 Brochure Way',
|
||||
postalCode: '33101',
|
||||
stage: 'details_sent',
|
||||
berthIdx: 0,
|
||||
},
|
||||
{
|
||||
tag: 'comms',
|
||||
fullName: 'Carla Communicating — in_communication',
|
||||
email: 'carla.comms@test.local',
|
||||
phone: '+1 555 010 0003',
|
||||
countryIso: 'ES',
|
||||
city: 'Palma',
|
||||
street: '3 Reply Street',
|
||||
postalCode: '07012',
|
||||
stage: 'in_communication',
|
||||
berthIdx: 5,
|
||||
},
|
||||
{
|
||||
tag: 'eoi-sent',
|
||||
fullName: 'Eric EoiSent — eoi_sent',
|
||||
email: 'eric.eoisent@test.local',
|
||||
phone: '+1 555 010 0004',
|
||||
countryIso: 'IT',
|
||||
city: 'Genoa',
|
||||
street: '4 Envelope Plaza',
|
||||
postalCode: '16124',
|
||||
stage: 'eoi_sent',
|
||||
berthIdx: 6,
|
||||
},
|
||||
{
|
||||
tag: 'eoi-signed',
|
||||
fullName: 'Sara EoiSigned — eoi_signed',
|
||||
email: 'sara.eoisigned@test.local',
|
||||
phone: '+1 555 010 0005',
|
||||
countryIso: 'FR',
|
||||
city: 'Nice',
|
||||
street: '5 Signed Avenue',
|
||||
postalCode: '06300',
|
||||
stage: 'eoi_signed',
|
||||
berthIdx: 7,
|
||||
},
|
||||
{
|
||||
tag: 'deposit',
|
||||
fullName: 'Dario Deposit — deposit_10pct',
|
||||
email: 'dario.deposit@test.local',
|
||||
phone: '+1 555 010 0006',
|
||||
countryIso: 'GR',
|
||||
city: 'Athens',
|
||||
street: '6 Deposit Quay',
|
||||
postalCode: '10558',
|
||||
stage: 'deposit_10pct',
|
||||
berthIdx: 8,
|
||||
},
|
||||
{
|
||||
tag: 'contract-sent',
|
||||
fullName: 'Connor ContractSent — contract_sent',
|
||||
email: 'connor.contract@test.local',
|
||||
phone: '+1 555 010 0007',
|
||||
countryIso: 'IE',
|
||||
city: 'Dublin',
|
||||
street: '7 Contract Row',
|
||||
postalCode: 'D02 E2X3',
|
||||
stage: 'contract_sent',
|
||||
berthIdx: 9,
|
||||
},
|
||||
{
|
||||
tag: 'contract-signed',
|
||||
fullName: 'Carmen ContractSigned — contract_signed',
|
||||
email: 'carmen.signed@test.local',
|
||||
phone: '+1 555 010 0008',
|
||||
countryIso: 'PT',
|
||||
city: 'Lisbon',
|
||||
street: '8 Notary Square',
|
||||
postalCode: '1100-001',
|
||||
stage: 'contract_signed',
|
||||
berthIdx: 4,
|
||||
},
|
||||
{
|
||||
tag: 'completed-won',
|
||||
fullName: 'Carlos Completed — completed (won)',
|
||||
email: 'carlos.complete@test.local',
|
||||
phone: '+1 555 010 0009',
|
||||
countryIso: 'PA',
|
||||
city: 'Panama City',
|
||||
street: '9 Owner Lane',
|
||||
postalCode: '0801',
|
||||
stage: 'completed',
|
||||
berthIdx: 10,
|
||||
outcome: 'won',
|
||||
},
|
||||
{
|
||||
tag: 'completed-lost',
|
||||
fullName: 'Lara LostLead — completed (lost)',
|
||||
email: 'lara.lost@test.local',
|
||||
phone: '+1 555 010 0010',
|
||||
countryIso: 'DE',
|
||||
city: 'Hamburg',
|
||||
street: '10 Other Marina',
|
||||
postalCode: '20457',
|
||||
stage: 'completed',
|
||||
berthIdx: 1,
|
||||
outcome: 'lost_unqualified',
|
||||
},
|
||||
{
|
||||
tag: 'archived-simple',
|
||||
fullName: 'Anna ArchivedSimple — archived',
|
||||
email: 'anna.archived@test.local',
|
||||
phone: '+1 555 010 0011',
|
||||
countryIso: 'NL',
|
||||
city: 'Amsterdam',
|
||||
street: '11 Quiet Path',
|
||||
postalCode: '1011',
|
||||
archive: 'simple',
|
||||
},
|
||||
{
|
||||
tag: 'archived-rich',
|
||||
fullName: 'Rita ArchivedRich — archived w/ metadata',
|
||||
email: 'rita.archivedrich@test.local',
|
||||
phone: '+1 555 010 0012',
|
||||
countryIso: 'BE',
|
||||
city: 'Antwerp',
|
||||
street: '12 Rich Metadata Blvd',
|
||||
postalCode: '2000',
|
||||
archive: 'rich',
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedSyntheticPortData(
|
||||
portId: string,
|
||||
portSlug: string,
|
||||
): Promise<SyntheticSeedSummary | null> {
|
||||
const existing = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.portId, portId))
|
||||
.limit(1);
|
||||
if (existing.length > 0) {
|
||||
console.log(` [${portSlug}] already seeded (clients exist), skipping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return withTransaction(async (tx) => {
|
||||
// ── 1. Berths ───────────────────────────────────────────────────────────
|
||||
// Same NocoDB snapshot as the realistic seed so the public map keeps
|
||||
// working. We override status for the moorings we link to so the
|
||||
// dossier UI shows the expected stake levels (under_offer / sold).
|
||||
const berthRows = await tx
|
||||
.insert(berths)
|
||||
.values(
|
||||
BERTH_SNAPSHOT.map((b) => ({
|
||||
portId,
|
||||
mooringNumber: b.mooringNumber,
|
||||
area: b.area,
|
||||
status: b.status,
|
||||
lengthFt: b.lengthFt != null ? String(b.lengthFt) : null,
|
||||
widthFt: b.widthFt != null ? String(b.widthFt) : null,
|
||||
draftFt: b.draftFt != null ? String(b.draftFt) : null,
|
||||
lengthM: b.lengthM != null ? String(b.lengthM) : null,
|
||||
widthM: b.widthM != null ? String(b.widthM) : null,
|
||||
draftM: b.draftM != null ? String(b.draftM) : null,
|
||||
widthIsMinimum: b.widthIsMinimum,
|
||||
nominalBoatSize: b.nominalBoatSize != null ? String(b.nominalBoatSize) : null,
|
||||
nominalBoatSizeM: b.nominalBoatSizeM != null ? String(b.nominalBoatSizeM) : null,
|
||||
waterDepth: b.waterDepth != null ? String(b.waterDepth) : null,
|
||||
waterDepthM: b.waterDepthM != null ? String(b.waterDepthM) : null,
|
||||
waterDepthIsMinimum: b.waterDepthIsMinimum,
|
||||
sidePontoon: b.sidePontoon,
|
||||
powerCapacity: b.powerCapacity != null ? String(b.powerCapacity) : null,
|
||||
voltage: b.voltage != null ? String(b.voltage) : null,
|
||||
mooringType: b.mooringType,
|
||||
cleatType: b.cleatType,
|
||||
cleatCapacity: b.cleatCapacity,
|
||||
bollardType: b.bollardType,
|
||||
bollardCapacity: b.bollardCapacity,
|
||||
access: b.access,
|
||||
price: b.price != null ? String(b.price) : null,
|
||||
priceCurrency: 'USD',
|
||||
bowFacing: b.bowFacing,
|
||||
berthApproved: b.berthApproved,
|
||||
statusOverrideMode: b.statusOverrideMode,
|
||||
tenureType: 'permanent' as const,
|
||||
})),
|
||||
)
|
||||
.returning({ id: berths.id, status: berths.status, mooringNumber: berths.mooringNumber });
|
||||
|
||||
// ── 2. Companies (one active, one with multiple memberships) ────────────
|
||||
const companyRows = await tx
|
||||
.insert(companies)
|
||||
.values([
|
||||
{
|
||||
portId,
|
||||
name: 'Test Charter Co.',
|
||||
legalName: 'Test Charter Company Ltd.',
|
||||
taxId: `TC-${portSlug}-001`,
|
||||
registrationNumber: 'TC-2024-0001',
|
||||
incorporationCountryIso: 'GB',
|
||||
incorporationDate: new Date('2024-01-01'),
|
||||
status: 'active',
|
||||
billingEmail: 'billing@testcharter.test.local',
|
||||
notes: 'Synthetic test company - has multiple member clients.',
|
||||
},
|
||||
])
|
||||
.returning({ id: companies.id, name: companies.name });
|
||||
const charterCoId = companyRows[0]!.id;
|
||||
|
||||
await tx.insert(companyAddresses).values([
|
||||
{
|
||||
companyId: charterCoId,
|
||||
portId,
|
||||
label: 'Head Office',
|
||||
streetAddress: '1 Test Street',
|
||||
city: 'London',
|
||||
subdivisionIso: null,
|
||||
postalCode: 'W1A 1AA',
|
||||
countryIso: 'GB',
|
||||
isPrimary: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// ── 3. Clients ──────────────────────────────────────────────────────────
|
||||
const clientRows = await tx
|
||||
.insert(clients)
|
||||
.values(
|
||||
PIPELINE_CLIENTS.map((spec) => ({
|
||||
portId,
|
||||
fullName: spec.fullName,
|
||||
nationalityIso: spec.countryIso,
|
||||
preferredContactMethod: 'email' as const,
|
||||
preferredLanguage: 'en',
|
||||
source: 'manual' as const,
|
||||
})),
|
||||
)
|
||||
.returning({ id: clients.id, fullName: clients.fullName });
|
||||
|
||||
const idByTag = new Map<string, string>();
|
||||
PIPELINE_CLIENTS.forEach((spec, i) => idByTag.set(spec.tag, clientRows[i]!.id));
|
||||
|
||||
// Contacts
|
||||
const contactValues: Array<typeof clientContacts.$inferInsert> = [];
|
||||
PIPELINE_CLIENTS.forEach((spec, i) => {
|
||||
const cid = clientRows[i]!.id;
|
||||
contactValues.push({
|
||||
clientId: cid,
|
||||
channel: 'email',
|
||||
value: spec.email,
|
||||
label: 'primary',
|
||||
isPrimary: true,
|
||||
});
|
||||
contactValues.push({
|
||||
clientId: cid,
|
||||
channel: 'phone',
|
||||
value: spec.phone,
|
||||
label: 'primary',
|
||||
isPrimary: false,
|
||||
});
|
||||
});
|
||||
await tx.insert(clientContacts).values(contactValues);
|
||||
|
||||
// Addresses
|
||||
await tx.insert(clientAddresses).values(
|
||||
PIPELINE_CLIENTS.map((spec, i) => ({
|
||||
clientId: clientRows[i]!.id,
|
||||
portId,
|
||||
label: 'Primary',
|
||||
streetAddress: spec.street,
|
||||
city: spec.city,
|
||||
subdivisionIso: null,
|
||||
postalCode: spec.postalCode,
|
||||
countryIso: spec.countryIso,
|
||||
isPrimary: true,
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 4. Yachts (the completed-won client gets one) ───────────────────────
|
||||
const completedWonId = idByTag.get('completed-won')!;
|
||||
const charterYachtRow = await tx
|
||||
.insert(yachts)
|
||||
.values([
|
||||
{
|
||||
portId,
|
||||
name: 'Test Wanderer',
|
||||
hullNumber: 'TW-001',
|
||||
flag: 'PA',
|
||||
yearBuilt: 2018,
|
||||
builder: 'Synthetic Yard',
|
||||
model: 'Cruiser 50',
|
||||
lengthFt: '50',
|
||||
widthFt: '15',
|
||||
draftFt: '6',
|
||||
currentOwnerType: 'client' as const,
|
||||
currentOwnerId: completedWonId,
|
||||
status: 'active' as const,
|
||||
notes: 'Owned by the completed-won test client.',
|
||||
},
|
||||
{
|
||||
portId,
|
||||
name: 'Charter Co. Flagship',
|
||||
hullNumber: 'CC-FLAG-001',
|
||||
flag: 'GB',
|
||||
yearBuilt: 2022,
|
||||
builder: 'Synthetic Yard',
|
||||
model: 'Sailing Yacht 55',
|
||||
lengthFt: '55',
|
||||
widthFt: '17',
|
||||
draftFt: '7',
|
||||
currentOwnerType: 'company' as const,
|
||||
currentOwnerId: charterCoId,
|
||||
status: 'active' as const,
|
||||
notes: 'Owned by Test Charter Co.',
|
||||
},
|
||||
])
|
||||
.returning({ id: yachts.id, name: yachts.name });
|
||||
|
||||
await tx.insert(yachtOwnershipHistory).values([
|
||||
{
|
||||
yachtId: charterYachtRow[0]!.id,
|
||||
ownerType: 'client',
|
||||
ownerId: completedWonId,
|
||||
startDate: daysAgo(180),
|
||||
endDate: null,
|
||||
transferReason: null,
|
||||
transferNotes: null,
|
||||
createdBy: SUPER_ADMIN_USER_ID,
|
||||
},
|
||||
{
|
||||
yachtId: charterYachtRow[1]!.id,
|
||||
ownerType: 'company',
|
||||
ownerId: charterCoId,
|
||||
startDate: daysAgo(365),
|
||||
endDate: null,
|
||||
transferReason: null,
|
||||
transferNotes: null,
|
||||
createdBy: SUPER_ADMIN_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
// ── 5. Memberships (link a couple of clients to Test Charter Co.) ──────
|
||||
const dirClientId = idByTag.get('contract-sent')!;
|
||||
const officerClientId = idByTag.get('eoi-signed')!;
|
||||
await tx.insert(companyMemberships).values([
|
||||
{
|
||||
companyId: charterCoId,
|
||||
clientId: dirClientId,
|
||||
role: 'director',
|
||||
roleDetail: 'Test director',
|
||||
startDate: daysAgo(120),
|
||||
endDate: null,
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
companyId: charterCoId,
|
||||
clientId: officerClientId,
|
||||
role: 'officer',
|
||||
roleDetail: 'Test officer',
|
||||
startDate: daysAgo(90),
|
||||
endDate: null,
|
||||
isPrimary: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// ── 6. Berth status overrides for linked moorings ───────────────────────
|
||||
// Match the dossier classification to the berth's pipeline stage.
|
||||
// For under_offer-wave clients (eoi_sent → contract_sent), force the
|
||||
// berth to under_offer. For completed-won, mark the berth sold.
|
||||
const stageToBerthStatus = (
|
||||
stage: PipelineStage | undefined,
|
||||
): 'available' | 'under_offer' | 'sold' | null => {
|
||||
if (!stage) return null;
|
||||
if (stage === 'completed') return 'sold';
|
||||
if (
|
||||
stage === 'eoi_sent' ||
|
||||
stage === 'eoi_signed' ||
|
||||
stage === 'deposit_10pct' ||
|
||||
stage === 'contract_sent' ||
|
||||
stage === 'contract_signed'
|
||||
) {
|
||||
return 'under_offer';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const spec of PIPELINE_CLIENTS) {
|
||||
if (spec.berthIdx === undefined) continue;
|
||||
const newStatus = stageToBerthStatus(spec.stage);
|
||||
if (!newStatus) continue;
|
||||
const berthId = berthRows[spec.berthIdx]!.id;
|
||||
await tx.update(berths).set({ status: newStatus }).where(eq(berths.id, berthId));
|
||||
}
|
||||
|
||||
// ── 7. Interests + interest_berths ──────────────────────────────────────
|
||||
let interestCount = 0;
|
||||
for (const spec of PIPELINE_CLIENTS) {
|
||||
if (!spec.stage) continue;
|
||||
const clientId = idByTag.get(spec.tag)!;
|
||||
const stageDaysAgoMap: Record<PipelineStage, number> = {
|
||||
open: 1,
|
||||
details_sent: 5,
|
||||
in_communication: 10,
|
||||
eoi_sent: 20,
|
||||
eoi_signed: 35,
|
||||
deposit_10pct: 60,
|
||||
contract_sent: 80,
|
||||
contract_signed: 110,
|
||||
completed: spec.outcome === 'won' ? 200 : 60,
|
||||
};
|
||||
const ageDays = stageDaysAgoMap[spec.stage];
|
||||
const yachtId = spec.tag === 'completed-won' ? charterYachtRow[0]!.id : null;
|
||||
|
||||
const [intRow] = await tx
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId,
|
||||
clientId,
|
||||
yachtId,
|
||||
pipelineStage: spec.stage,
|
||||
leadCategory:
|
||||
spec.stage === 'open'
|
||||
? 'general_interest'
|
||||
: spec.stage === 'details_sent' || spec.stage === 'in_communication'
|
||||
? 'specific_qualified'
|
||||
: 'hot_lead',
|
||||
source: 'manual' as const,
|
||||
dateFirstContact: daysAgo(ageDays),
|
||||
dateLastContact: daysAgo(Math.max(0, ageDays - 2)),
|
||||
dateEoiSent:
|
||||
spec.stage === 'eoi_sent' ||
|
||||
spec.stage === 'eoi_signed' ||
|
||||
spec.stage === 'deposit_10pct' ||
|
||||
spec.stage === 'contract_sent' ||
|
||||
spec.stage === 'contract_signed' ||
|
||||
spec.stage === 'completed'
|
||||
? daysAgo(Math.max(0, ageDays - 5))
|
||||
: null,
|
||||
dateEoiSigned:
|
||||
spec.stage === 'eoi_signed' ||
|
||||
spec.stage === 'deposit_10pct' ||
|
||||
spec.stage === 'contract_sent' ||
|
||||
spec.stage === 'contract_signed' ||
|
||||
spec.stage === 'completed'
|
||||
? daysAgo(Math.max(0, ageDays - 10))
|
||||
: null,
|
||||
eoiStatus:
|
||||
spec.stage === 'eoi_sent'
|
||||
? 'waiting_for_signatures'
|
||||
: spec.stage === 'eoi_signed' ||
|
||||
spec.stage === 'deposit_10pct' ||
|
||||
spec.stage === 'contract_sent' ||
|
||||
spec.stage === 'contract_signed' ||
|
||||
spec.stage === 'completed'
|
||||
? 'signed'
|
||||
: null,
|
||||
outcome: spec.outcome ?? null,
|
||||
outcomeAt: spec.outcome ? daysAgo(7) : null,
|
||||
outcomeReason:
|
||||
spec.outcome === 'lost_unqualified' ? 'Synthetic test: not qualified.' : null,
|
||||
})
|
||||
.returning({ id: interests.id });
|
||||
interestCount += 1;
|
||||
|
||||
if (spec.berthIdx !== undefined) {
|
||||
const berthId = berthRows[spec.berthIdx]!.id;
|
||||
await tx.insert(interestBerths).values({
|
||||
interestId: intRow!.id,
|
||||
berthId,
|
||||
isPrimary: true,
|
||||
isSpecificInterest: true,
|
||||
isInEoiBundle: spec.stage !== 'open' && spec.stage !== 'details_sent',
|
||||
addedBy: SUPER_ADMIN_USER_ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 8. Multi-interest client ────────────────────────────────────────────
|
||||
// Adds a second interest to "carla.comms" so the dossier shows
|
||||
// multiple deals on the same client.
|
||||
const carlaId = idByTag.get('comms')!;
|
||||
const [secondInt] = await tx
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId,
|
||||
clientId: carlaId,
|
||||
yachtId: null,
|
||||
pipelineStage: 'open',
|
||||
leadCategory: 'general_interest',
|
||||
source: 'website' as const,
|
||||
dateFirstContact: daysAgo(2),
|
||||
dateLastContact: daysAgo(1),
|
||||
})
|
||||
.returning({ id: interests.id });
|
||||
await tx.insert(interestBerths).values({
|
||||
interestId: secondInt!.id,
|
||||
berthId: berthRows[2]!.id,
|
||||
isPrimary: true,
|
||||
isSpecificInterest: true,
|
||||
isInEoiBundle: false,
|
||||
addedBy: SUPER_ADMIN_USER_ID,
|
||||
});
|
||||
|
||||
// ── 9. Reservations ─────────────────────────────────────────────────────
|
||||
// One active reservation on the under_offer berth held by Carla,
|
||||
// one cancelled on an available berth.
|
||||
// berthReservations requires a yacht — wire both to the charter co.
|
||||
// flagship since Carla / Olivia don't own yachts yet.
|
||||
const sharedYachtId = charterYachtRow[1]!.id;
|
||||
await tx.insert(berthReservations).values([
|
||||
{
|
||||
portId,
|
||||
berthId: berthRows[5]!.id,
|
||||
clientId: carlaId,
|
||||
yachtId: sharedYachtId,
|
||||
startDate: daysAgo(10),
|
||||
endDate: null,
|
||||
status: 'active',
|
||||
notes: 'Synthetic active reservation.',
|
||||
createdBy: SUPER_ADMIN_USER_ID,
|
||||
},
|
||||
{
|
||||
portId,
|
||||
berthId: berthRows[3]!.id,
|
||||
clientId: idByTag.get('open')!,
|
||||
yachtId: sharedYachtId,
|
||||
startDate: daysAgo(30),
|
||||
endDate: daysAgo(20),
|
||||
status: 'cancelled',
|
||||
notes: 'Synthetic cancelled reservation.',
|
||||
createdBy: SUPER_ADMIN_USER_ID,
|
||||
},
|
||||
]);
|
||||
|
||||
// ── 10. Apply archive metadata for Anna + Rita ──────────────────────────
|
||||
const annaId = idByTag.get('archived-simple')!;
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({
|
||||
archivedAt: daysAgo(30),
|
||||
// archived_by FK references the better-auth user table; the
|
||||
// synthetic super-admin is just a profile placeholder so we
|
||||
// leave this null. Field is set to the actual operator id by
|
||||
// the smart-archive service in production code paths.
|
||||
archivedBy: null,
|
||||
archiveReason: '',
|
||||
archiveMetadata: null,
|
||||
})
|
||||
.where(eq(clients.id, annaId));
|
||||
|
||||
// Rich-archive: fabricate a metadata payload that the smart-restore
|
||||
// wizard will surface as auto-reversible (berth still available) +
|
||||
// opt-in-to-undo (yacht transferred).
|
||||
const ritaId = idByTag.get('archived-rich')!;
|
||||
const richMetadata: ArchiveMetadata = {
|
||||
decisions: [
|
||||
{
|
||||
kind: 'berth_released',
|
||||
refId: berthRows[2]!.id,
|
||||
detail: { mooringNumber: berthRows[2]!.mooringNumber },
|
||||
},
|
||||
{
|
||||
kind: 'yacht_transferred',
|
||||
refId: charterYachtRow[1]!.id,
|
||||
detail: { newOwnerType: 'company', newOwnerId: charterCoId },
|
||||
},
|
||||
],
|
||||
decidedAt: daysAgo(20).toISOString(),
|
||||
decidedBy: SUPER_ADMIN_USER_ID,
|
||||
reason: 'Synthetic rich-archive for restore wizard testing.',
|
||||
};
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({
|
||||
archivedAt: daysAgo(20),
|
||||
archivedBy: null,
|
||||
archiveReason: richMetadata.reason,
|
||||
archiveMetadata: richMetadata,
|
||||
})
|
||||
.where(eq(clients.id, ritaId));
|
||||
|
||||
// ── 11. Residential pipeline (one per stage cluster) ────────────────────
|
||||
const residentialRows = await tx
|
||||
.insert(residentialClients)
|
||||
.values([
|
||||
{
|
||||
portId,
|
||||
fullName: 'Robert Resident',
|
||||
email: 'robert.resident@test.local',
|
||||
phone: '+1 555 020 0001',
|
||||
source: 'website' as const,
|
||||
notes: 'Synthetic residential lead.',
|
||||
},
|
||||
{
|
||||
portId,
|
||||
fullName: 'Rina Resident',
|
||||
email: 'rina.resident@test.local',
|
||||
phone: '+1 555 020 0002',
|
||||
source: 'referral' as const,
|
||||
notes: 'Synthetic residential lead — qualified.',
|
||||
},
|
||||
])
|
||||
.returning({ id: residentialClients.id });
|
||||
|
||||
await tx.insert(residentialInterests).values([
|
||||
{
|
||||
portId,
|
||||
residentialClientId: residentialRows[0]!.id,
|
||||
pipelineStage: 'new',
|
||||
notes: 'Synthetic residential interest at "new" stage.',
|
||||
dateFirstContact: daysAgo(2),
|
||||
},
|
||||
{
|
||||
portId,
|
||||
residentialClientId: residentialRows[1]!.id,
|
||||
pipelineStage: 'contacted',
|
||||
notes: 'Synthetic residential interest at "contacted" stage.',
|
||||
dateFirstContact: daysAgo(7),
|
||||
dateLastContact: daysAgo(2),
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
berths: berthRows.length,
|
||||
clients: clientRows.length,
|
||||
interests: interestCount + 1, // +1 for Carla's second interest
|
||||
companies: 1,
|
||||
yachts: charterYachtRow.length,
|
||||
residentialClients: residentialRows.length,
|
||||
} satisfies SyntheticSeedSummary;
|
||||
});
|
||||
}
|
||||
55
src/lib/db/seed-synthetic.ts
Normal file
55
src/lib/db/seed-synthetic.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Synthetic seed (the "every pipeline stage" fixture).
|
||||
*
|
||||
* Bootstraps the same ports/roles/profile as `seed.ts` then loads
|
||||
* `seedSyntheticPortData()` per port — 12 clients, one per pipeline
|
||||
* stage plus archive variants, designed for thoroughly testing the
|
||||
* CRM end-to-end.
|
||||
*
|
||||
* Use the realistic seed (`pnpm db:seed`) for shapes that mirror the
|
||||
* production NocoDB clone.
|
||||
*
|
||||
* Run with: pnpm db:seed:synthetic
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { seedBootstrap } from './seed-bootstrap';
|
||||
import { seedSyntheticPortData, type SyntheticSeedSummary } from './seed-synthetic-data';
|
||||
|
||||
async function seed() {
|
||||
console.log('Seeding Port Nimara CRM (synthetic test fixture)...');
|
||||
|
||||
const portIds = await seedBootstrap();
|
||||
|
||||
console.log('');
|
||||
console.log('Seeding per-port synthetic fixtures...');
|
||||
|
||||
const summaries: Array<{ name: string; summary: SyntheticSeedSummary | null }> = [];
|
||||
for (const p of portIds) {
|
||||
console.log(` [${p.slug}] seeding synthetic data...`);
|
||||
const summary = await seedSyntheticPortData(p.id, p.slug);
|
||||
summaries.push({ name: p.name, summary });
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('─── Summary ───────────────────────────────────────────────');
|
||||
for (const s of summaries) {
|
||||
if (s.summary === null) {
|
||||
console.log(` ✓ Port "${s.name}" - already seeded (skipped)`);
|
||||
} else {
|
||||
const x = s.summary;
|
||||
console.log(
|
||||
` ✓ Port "${s.name}" - ${x.berths} berths, ${x.clients} clients, ${x.companies} companies, ${x.yachts} yachts, ${x.interests} interests, ${x.residentialClients} residential clients`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
console.log('Synthetic seed complete!');
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error('Synthetic seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,654 +1,26 @@
|
||||
/**
|
||||
* Seed script for Port Nimara CRM.
|
||||
* Realistic seed (the "production-shaped" fixture).
|
||||
*
|
||||
* Top-level orchestrator:
|
||||
* 1. Create the operational ports (idempotent):
|
||||
* - Port Nimara (primary install - the real marina)
|
||||
* - Port Amador (secondary, kept for multi-tenant isolation tests
|
||||
* and as scaffolding for a future Panama install)
|
||||
* 2. Create 5 system roles with full permission maps
|
||||
* 3. Create the super admin user profile placeholder (matt@portnimara.com)
|
||||
* 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts
|
||||
* to produce the realistic multi-cardinality fixture
|
||||
* (117 berths from the NocoDB snapshot, plus clients, companies, yachts,
|
||||
* memberships, interests, reservations, ownership-transfer history).
|
||||
* 5. Print a summary.
|
||||
* Bootstraps ports + roles + super-admin profile, then runs
|
||||
* `seedPortData()` per port to load the NocoDB-shaped multi-cardinality
|
||||
* fixture (117 berths, 8 clients, 3 companies, 12 yachts, 15 interests,
|
||||
* 8 reservations).
|
||||
*
|
||||
* For a focused test fixture covering every pipeline stage + archive
|
||||
* variants, use `pnpm db:seed:synthetic` instead.
|
||||
*
|
||||
* Run with: pnpm db:seed
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { ports } from './schema/ports';
|
||||
import { roles, userProfiles } from './schema/users';
|
||||
import type { RolePermissions } from './schema/users';
|
||||
import { seedBootstrap } from './seed-bootstrap';
|
||||
import { seedPortData, type SeedSummary } from './seed-data';
|
||||
|
||||
// ─── Permission Maps ─────────────────────────────────────────────────────────
|
||||
|
||||
const ALL_PERMISSIONS: RolePermissions = {
|
||||
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,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: 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, edit: 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,
|
||||
permanently_delete_clients: true,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
|
||||
const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
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,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: 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, edit: 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: false,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: true },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: true,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
|
||||
const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true },
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
send_for_signing: true,
|
||||
upload_signed: true,
|
||||
delete: false,
|
||||
},
|
||||
expenses: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
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, edit: true, delete: false, 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: 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: false,
|
||||
manage_settings: false,
|
||||
manage_webhooks: false,
|
||||
manage_reports: false,
|
||||
manage_custom_fields: false,
|
||||
manage_forms: false,
|
||||
manage_tags: true,
|
||||
system_backup: false,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true },
|
||||
interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
change_stage: true,
|
||||
override_stage: true,
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
send_for_signing: true,
|
||||
upload_signed: true,
|
||||
delete: false,
|
||||
},
|
||||
expenses: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
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, edit: false, delete: false, manage_folders: false },
|
||||
email: { view: true, send: true, configure_account: true },
|
||||
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: true, export: true },
|
||||
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: true,
|
||||
system_backup: false,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
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,
|
||||
override_stage: false,
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
||||
documents: {
|
||||
view: true,
|
||||
create: false,
|
||||
edit: 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, edit: 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,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: false, create: false, edit: false, delete: false },
|
||||
residential_interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Residential Partner — for an outside party who handles residential
|
||||
// inquiries on the marina's behalf. Sees only the residential pages and
|
||||
// nothing else; can't see marina clients, yachts, berths, EOIs, etc.
|
||||
const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false },
|
||||
interests: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
change_stage: false,
|
||||
override_stage: false,
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
send_for_signing: false,
|
||||
upload_signed: false,
|
||||
delete: false,
|
||||
},
|
||||
expenses: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
export: false,
|
||||
scan_receipt: false,
|
||||
},
|
||||
invoices: {
|
||||
view: false,
|
||||
create: false,
|
||||
edit: false,
|
||||
delete: false,
|
||||
send: false,
|
||||
record_payment: false,
|
||||
export: false,
|
||||
},
|
||||
files: { view: false, upload: false, edit: false, delete: false, manage_folders: false },
|
||||
email: { view: false, send: false, configure_account: false },
|
||||
reminders: {
|
||||
view_own: true,
|
||||
view_all: false,
|
||||
create: true,
|
||||
edit_own: true,
|
||||
edit_all: false,
|
||||
assign_others: false,
|
||||
},
|
||||
calendar: { connect: false, view_events: false },
|
||||
reports: { view_dashboard: false, view_analytics: false, export: false },
|
||||
document_templates: { view: false, generate: false, manage: false },
|
||||
yachts: { view: false, create: false, edit: false, delete: false, transfer: false },
|
||||
companies: { view: false, create: false, edit: false, delete: false },
|
||||
memberships: { view: false, manage: false },
|
||||
reservations: { view: false, 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,
|
||||
permanently_delete_clients: false,
|
||||
},
|
||||
residential_clients: { view: true, create: true, edit: true, delete: false },
|
||||
residential_interests: {
|
||||
view: true,
|
||||
create: true,
|
||||
edit: true,
|
||||
delete: false,
|
||||
change_stage: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Port Definitions ────────────────────────────────────────────────────────
|
||||
|
||||
const PORT_DEFINITIONS: Array<{
|
||||
name: string;
|
||||
slug: string;
|
||||
primaryColor: string;
|
||||
defaultCurrency: string;
|
||||
timezone: string;
|
||||
}> = [
|
||||
{
|
||||
name: 'Port Nimara',
|
||||
slug: 'port-nimara',
|
||||
primaryColor: '#0F4C81',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Anguilla',
|
||||
},
|
||||
// Second port kept for multi-tenant isolation tests (cross-port scoping,
|
||||
// permission boundaries). Drop or rename if the production install is
|
||||
// single-port.
|
||||
{
|
||||
name: 'Port Amador',
|
||||
slug: 'port-amador',
|
||||
primaryColor: '#D97706',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Panama',
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Seed Function ────────────────────────────────────────────────────────────
|
||||
|
||||
async function seed() {
|
||||
console.log('Seeding Port Nimara CRM...');
|
||||
console.log('Seeding Port Nimara CRM (realistic fixture)...');
|
||||
|
||||
// ── 1. Ports ────────────────────────────────────────────────────────────────
|
||||
console.log('Creating ports...');
|
||||
const portIds: Array<{ id: string; name: string; slug: string }> = [];
|
||||
const portIds = await seedBootstrap();
|
||||
|
||||
for (const def of PORT_DEFINITIONS) {
|
||||
const [inserted] = await db
|
||||
.insert(ports)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: def.name,
|
||||
slug: def.slug,
|
||||
logoUrl: null,
|
||||
primaryColor: def.primaryColor,
|
||||
defaultCurrency: def.defaultCurrency,
|
||||
timezone: def.timezone,
|
||||
settings: {},
|
||||
isActive: true,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (inserted) {
|
||||
console.log(` Port created: ${def.name} (${inserted.id})`);
|
||||
portIds.push({ id: inserted.id, name: def.name, slug: def.slug });
|
||||
} else {
|
||||
// Port already existed - look it up so we can still seed fixtures for it.
|
||||
const [existing] = await db.select().from(ports).where(eq(ports.slug, def.slug)).limit(1);
|
||||
if (existing) {
|
||||
console.log(` Port exists: ${def.name} (${existing.id})`);
|
||||
portIds.push({ id: existing.id, name: def.name, slug: def.slug });
|
||||
} else {
|
||||
console.warn(` Port insert conflict but lookup returned no row: ${def.slug}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. System Roles ─────────────────────────────────────────────────────────
|
||||
console.log('Creating system roles...');
|
||||
|
||||
const systemRoles = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'super_admin',
|
||||
description: 'Full system access. Bypasses all permission checks.',
|
||||
permissions: ALL_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'director',
|
||||
description: 'Operational admin within assigned port(s). Can manage users and settings.',
|
||||
permissions: DIRECTOR_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'sales_manager',
|
||||
description: 'Full sales access. Can view all reminders, assign tasks, and export reports.',
|
||||
permissions: SALES_MANAGER_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'sales_agent',
|
||||
description:
|
||||
'Standard sales role. View/create/edit clients and interests, manage own reminders.',
|
||||
permissions: SALES_AGENT_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'viewer',
|
||||
description: 'Read-only access to all records.',
|
||||
permissions: VIEWER_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: 'residential_partner',
|
||||
description:
|
||||
'External partner who handles residential inquiries. Sees only the residential pages — no marina clients, yachts, berths, or financial data.',
|
||||
permissions: RESIDENTIAL_PARTNER_PERMISSIONS,
|
||||
isGlobal: true,
|
||||
isSystem: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const role of systemRoles) {
|
||||
await db.insert(roles).values(role).onConflictDoNothing();
|
||||
console.log(` Role: ${role.name}`);
|
||||
}
|
||||
|
||||
// ── 3. Super Admin User Profile ─────────────────────────────────────────────
|
||||
// Note: Better Auth creates the actual `user` record on first login.
|
||||
// We create the profile extension now, linked to a known user_id.
|
||||
// The Better Auth user_id for matt@portnimara.com must match this value
|
||||
// once Better Auth is configured. Use a stable placeholder ID here.
|
||||
console.log('Creating super admin user profile...');
|
||||
|
||||
const superAdminUserId = 'super-admin-matt-portnimara';
|
||||
|
||||
await db
|
||||
.insert(userProfiles)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: superAdminUserId,
|
||||
displayName: 'Matt',
|
||||
avatarUrl: null,
|
||||
phone: null,
|
||||
isSuperAdmin: true,
|
||||
isActive: true,
|
||||
lastLoginAt: null,
|
||||
preferences: {},
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
console.log(` Super admin profile for user_id: ${superAdminUserId}`);
|
||||
|
||||
// ── 4. Per-port fixtures ────────────────────────────────────────────────────
|
||||
console.log('');
|
||||
console.log('Seeding per-port fixtures...');
|
||||
|
||||
@@ -659,7 +31,6 @@ async function seed() {
|
||||
summaries.push({ name: p.name, summary });
|
||||
}
|
||||
|
||||
// ── 5. Summary ─────────────────────────────────────────────────────────────
|
||||
console.log('');
|
||||
console.log('─── Summary ───────────────────────────────────────────────');
|
||||
for (const s of summaries) {
|
||||
@@ -674,10 +45,6 @@ async function seed() {
|
||||
}
|
||||
console.log('');
|
||||
console.log('Seed complete!');
|
||||
console.log('');
|
||||
console.log('NOTE: The Better Auth user for matt@portnimara.com must be created');
|
||||
console.log(`separately. Once created, update user_profiles.user_id to match`);
|
||||
console.log(`the actual Better Auth user ID (currently placeholder: ${superAdminUserId})`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user