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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
310568690b
commit
97e1d4c60b
@@ -0,0 +1,268 @@
|
||||
// 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();
|
||||
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user