2026-07-16 13:18:10 -04:00
|
|
|
|
// 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.
|
2026-07-17 13:34:33 -04:00
|
|
|
|
|
|
|
|
|
|
// 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).
|
2026-07-16 13:18:10 -04:00
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
|
|
// 3–5 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 5–8 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 15–30 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2–4 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);
|
|
|
|
|
|
});
|