Files
elegalsoftware/scripts/seed-demo.ts
T

306 lines
12 KiB
TypeScript
Raw Normal View History

// Seeds 10 demo firms, each with one owner user + realistic activity.
// Run from monorepo root: npx tsx scripts/seed-demo.ts
//
// Login: any of the emails below with password `Demo1234!`
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, '../.env') });
import argon2 from 'argon2';
import {
getDb,
getPool,
firms,
users,
clients,
cases,
timeEntries,
invoices,
invoiceItems,
} from '@lawdesk/db';
// ─── Demo data pools ───────────────────────────────────────────────────────
const FIRMS = [
{ name: 'Hartwell & Associates', plan: 'pro' as const, area: 'Family Law' },
{ name: 'Brennan Law Group', plan: 'lifetime' as const, area: 'Personal Injury' },
{ name: 'Cohen Legal Solutions', plan: 'starter' as const, area: 'Criminal Defense' },
{ name: 'Davenport & Reed PLLC', plan: 'pro' as const, area: 'Real Estate' },
{ name: 'Eastman Law Firm', plan: 'starter' as const, area: 'Employment' },
{ name: 'Fairmont Legal Partners', plan: 'pro' as const, area: 'Estate Planning' },
{ name: 'Gallo & Whitfield LLP', plan: 'lifetime' as const, area: 'Corporate' },
{ name: 'Hayes Immigration Law', plan: 'starter' as const, area: 'Immigration' },
{ name: 'Iverson & Marsh', plan: 'pro' as const, area: 'Bankruptcy' },
{ name: 'Jensen Tax Law', plan: 'pro' as const, area: 'Tax' },
];
const FIRST_NAMES = ['Emma', 'Liam', 'Olivia', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Mason', 'Isabella', 'James', 'Mia', 'Lucas', 'Charlotte', 'Henry', 'Amelia'];
const LAST_NAMES = ['Anderson', 'Brown', 'Carter', 'Davis', 'Evans', 'Foster', 'Garcia', 'Hill', 'Jackson', 'King', 'Lee', 'Morris', 'Nelson', 'Owens', 'Parker', 'Quinn', 'Rivera', 'Stone', 'Taylor', 'Walker'];
const CASE_TITLES: Record<string, string[]> = {
'Family Law': ['Divorce — assets division', 'Child custody modification', 'Prenuptial agreement', 'Adoption petition', 'Spousal support'],
'Personal Injury': ['Auto accident — rear-end', 'Slip and fall at retail', 'Workplace injury claim', 'Medical malpractice', 'Product liability'],
'Criminal Defense': ['DUI defense', 'Assault charge', 'Drug possession', 'White-collar fraud', 'Theft defense'],
'Real Estate': ['Commercial lease review', 'Title dispute', 'Zoning variance', 'Purchase agreement', 'Easement dispute'],
'Employment': ['Wrongful termination', 'Discrimination claim', 'Wage and hour dispute', 'Non-compete enforcement', 'Severance negotiation'],
'Estate Planning': ['Trust formation', 'Will drafting', 'Probate administration', 'Power of attorney', 'Estate dispute'],
'Corporate': ['M&A advisory', 'Shareholder agreement', 'Series A financing', 'IP licensing', 'Corporate restructuring'],
'Immigration': ['H-1B visa petition', 'Green card application', 'Asylum case', 'Naturalization', 'Family-based visa'],
'Bankruptcy': ['Chapter 7 filing', 'Chapter 13 reorganization', 'Creditor negotiation', 'Asset protection', 'Discharge defense'],
'Tax': ['IRS audit defense', 'Tax debt settlement', 'Estate tax planning', 'Business tax structuring', 'Tax court appeal'],
};
const TIME_DESCRIPTIONS = [
'Initial client consultation',
'Drafted demand letter',
'Reviewed discovery documents',
'Court appearance — motion hearing',
'Phone call with opposing counsel',
'Research case law',
'Prepared deposition outline',
'Client meeting — strategy review',
'Email correspondence',
'Filed motion to compel',
'Mediation session',
'Drafted settlement proposal',
'Reviewed contract terms',
'Prepared trial exhibits',
'Settlement conference',
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!;
}
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function daysAgo(days: number): Date {
const d = new Date();
d.setDate(d.getDate() - days);
return d;
}
function slug(s: string): string {
return s.toLowerCase().replace(/[^a-z]+/g, '');
}
// ─── Safety guard ─────────────────────────────────────────────────────────────
// This inserts 10 demo firms whose owner logins all use the public password `Demo1234!`
// into whatever DATABASE_URL points at — which for this project is the PRODUCTION database.
// Hard stop: demo data must NEVER be seeded into production. This runs FIRST and cannot be
// overridden by ALLOW_SEED — a single env opt-in is too weak a guard for planting
// known-credential login accounts in prod.
{
const dbHost = (() => {
try {
return new URL(process.env.DATABASE_URL ?? '').host || 'unknown';
} catch {
return 'unknown';
}
})();
// Required refusal: never seed when running in a production environment.
if (process.env.NODE_ENV === 'production') {
console.error(
'Refusing to seed: NODE_ENV=production.\n' +
'seed-demo.ts creates ~10 demo owner accounts with the public password "Demo1234!".\n' +
'Demo data must NEVER be seeded into production under any circumstances.\n' +
'This refusal is absolute and cannot be overridden with ALLOW_SEED.\n' +
`Target database host: ${dbHost}`,
);
process.exit(1);
}
// Belt-and-suspenders: also refuse if DATABASE_URL points at the known production DB host,
// even if NODE_ENV was left unset. Production Postgres lives on DigitalOcean managed DBs.
if (dbHost.endsWith('.db.ondigitalocean.com')) {
console.error(
`Refusing to seed: DATABASE_URL host "${dbHost}" is the production database.\n` +
'seed-demo.ts plants known-credential demo accounts and must never touch production.\n' +
'This refusal cannot be overridden with ALLOW_SEED.',
);
process.exit(1);
}
}
// Require an explicit opt-in so it can never run by accident (second gate, non-production only).
if (process.env.ALLOW_SEED !== '1') {
const host = (() => {
try {
return new URL(process.env.DATABASE_URL ?? '').host || 'unknown';
} catch {
return 'unknown';
}
})();
console.error(
`Refusing to seed. This writes 10 demo firms (owner password "Demo1234!") to: ${host}\n` +
'That is the production database for this project. Re-run with ALLOW_SEED=1 only if you are sure:\n' +
' ALLOW_SEED=1 npx tsx scripts/seed-demo.ts',
);
process.exit(1);
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function main() {
const db = getDb();
const passwordHash = await argon2.hash('Demo1234!', {
type: argon2.argon2id,
memoryCost: 64 * 1024,
timeCost: 3,
parallelism: 1,
});
console.log('Seeding 10 firms with activity…\n');
for (let i = 0; i < FIRMS.length; i++) {
const def = FIRMS[i]!;
const firstName = FIRST_NAMES[i]!;
const lastName = LAST_NAMES[i]!;
const fullName = `${firstName} ${lastName}`;
const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}@${slug(def.name)}.test`;
// Firm
const [firm] = await db.insert(firms).values({
name: def.name,
plan: def.plan,
watermarkEnabled: def.plan === 'starter',
}).returning();
// Owner user
const [user] = await db.insert(users).values({
firmId: firm.id,
email,
passwordHash,
fullName,
role: 'owner',
emailVerifiedAt: new Date(),
lastSeenAt: daysAgo(randInt(0, 5)),
}).returning();
// 35 clients
const numClients = randInt(3, 5);
const clientRows = [];
for (let c = 0; c < numClients; c++) {
const cName = `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`;
const [client] = await db.insert(clients).values({
firmId: firm.id,
name: cName,
email: `${cName.toLowerCase().replace(' ', '.')}@example.com`,
phone: `(${randInt(200, 999)}) ${randInt(200, 999)}-${randInt(1000, 9999)}`,
}).returning();
clientRows.push(client);
}
// 58 cases
const numCases = randInt(5, 8);
const caseTitles = CASE_TITLES[def.area]!;
const caseRows = [];
for (let cc = 0; cc < numCases; cc++) {
const status = cc < numCases - 2 ? 'open' : (Math.random() > 0.5 ? 'pending' : 'closed');
const hourlyRate = randInt(150, 450);
const openedDaysAgo = randInt(7, 120);
const [kase] = await db.insert(cases).values({
firmId: firm.id,
clientId: pick(clientRows).id,
title: caseTitles[cc % caseTitles.length]!,
caseNumber: `${new Date().getFullYear()}-${String(cc + 1).padStart(4, '0')}`,
status,
practiceArea: def.area,
hourlyRate: String(hourlyRate),
openedAt: daysAgo(openedDaysAgo),
closedAt: status === 'closed' ? daysAgo(randInt(0, openedDaysAgo - 1)) : null,
}).returning();
caseRows.push(kase);
}
// 1530 time entries spread across cases
const numEntries = randInt(15, 30);
for (let e = 0; e < numEntries; e++) {
const kase = pick(caseRows);
const minutes = randInt(15, 240);
const startedDaysAgo = randInt(0, 60);
const startedAt = daysAgo(startedDaysAgo);
const endedAt = new Date(startedAt.getTime() + minutes * 60 * 1000);
await db.insert(timeEntries).values({
firmId: firm.id,
caseId: kase.id,
userId: user.id,
description: pick(TIME_DESCRIPTIONS),
startedAt,
endedAt,
minutes,
rate: kase.hourlyRate ?? '250',
billable: Math.random() > 0.15,
});
}
// 24 invoices
const numInvoices = randInt(2, 4);
for (let inv = 0; inv < numInvoices; inv++) {
const kase = pick(caseRows);
const client = clientRows.find((c) => c.id === kase.clientId)!;
const numItems = randInt(2, 5);
const items = [];
let subtotal = 0;
for (let it = 0; it < numItems; it++) {
const qty = Number((Math.random() * 8 + 0.5).toFixed(2));
const rate = Number(kase.hourlyRate ?? 250);
const amount = Number((qty * rate).toFixed(2));
subtotal += amount;
items.push({ description: pick(TIME_DESCRIPTIONS), quantity: qty, rate, amount, sortOrder: it });
}
const total = Number(subtotal.toFixed(2));
// Status mix: ~40% sent, ~25% paid, ~15% overdue, ~15% draft, ~5% void
const r = Math.random();
const status = r < 0.4 ? 'sent' : r < 0.65 ? 'paid' : r < 0.8 ? 'overdue' : r < 0.95 ? 'draft' : 'void';
const issuedDaysAgo = randInt(5, 90);
const issuedAt = status === 'draft' ? null : daysAgo(issuedDaysAgo);
const dueAt = issuedAt ? new Date(issuedAt.getTime() + 30 * 24 * 60 * 60 * 1000) : null;
const paidAt = status === 'paid' ? daysAgo(randInt(1, issuedDaysAgo - 1)) : null;
const [invoice] = await db.insert(invoices).values({
firmId: firm.id,
clientId: client.id,
caseId: kase.id,
number: `INV-${new Date().getFullYear()}-${String(inv + 1 + i * 10).padStart(4, '0')}`,
status,
subtotal: subtotal.toFixed(2),
taxRate: '0',
total: total.toFixed(2),
issuedAt,
dueAt,
paidAt,
}).returning();
for (const it of items) {
await db.insert(invoiceItems).values({
invoiceId: invoice.id,
description: it.description,
quantity: String(it.quantity),
rate: String(it.rate),
amount: String(it.amount),
sortOrder: it.sortOrder,
});
}
}
console.log(` ✓ ${def.name.padEnd(32)}${email} (${def.plan})`);
}
console.log('\nDone. All 10 users use password: Demo1234!');
await getPool().end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});