Files
elegalsoftware/scripts/seed-demo.ts
T
Leon SerfatyandClaude Fable 5 97e1d4c60b Storage→Spaces, security hardening, production-blocker fixes, tests + CI
Storage
- Migrate document/media storage from local disk to DigitalOcean Spaces (S3);
  lib/storage.ts now streams via the S3 SDK; SPACES_* env vars required.
- Add scripts/migrate-storage-to-spaces.ts (idempotent, one-time).

Security hardening (all report findings)
- DB pool fails closed in production when the CA cert is missing (no more
  silent unverified TLS); warns in dev.
- trustProxy: 1 (was true) so X-Forwarded-For can't be spoofed to evade rate limits.
- Login lockout keyed by (email, ip) so an attacker can't lock out a victim.
- Superadmin auto-grant now requires a verified email.
- CSRF tokens HMAC-signed; exact-path exemptions; logout no longer exempt.
- Upload content-sniffing (magic bytes) rejects spoofed MIME types.
- create-admin.ts reads creds from env/argv; seed-demo.ts guarded behind ALLOW_SEED.

Production-blocker fixes
- SPA deep-link/refresh no longer 500s (decorateReply fix); index.html served no-cache.
- Invoice numbering is transaction-safe (per-firm advisory lock + max sequence),
  eliminating concurrent collisions and delete-reuse — no schema change.
- Checkout guards against double-billing a firm already on a paid plan.
- Fix render-loop in CreateInvoiceDrawer / ManualEntryDrawer (unstable effect deps).

Honesty / trust
- Remove fabricated testimonials, stats, strikethrough "was" prices, contact SLA,
  and the login-panel stats; replace with non-fabricated copy.
- Fix cookie-policy consent-key mismatch. (Legal pages still need lawyer review.)

Quality
- Add Vitest unit tests (file-signature, password hashing) and GitHub Actions CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:18:10 -04:00

269 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
// Require an explicit opt-in so it can never run by accident.
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);
});