/** * Seed 10 real, login-capable users — each with a full graph of activity. * * Passwords are hashed with Better Auth's own `hashPassword` (node scrypt), * and credential accounts are written in the exact shape Better Auth expects * (`providerId: "credential"`, `accountId: `), so every seeded user can * actually log in. NO plaintext passwords are ever stored. * * Run: npx tsx scripts/seed-users.ts * * Re-runnable: existing users with the seeded emails are deleted first (FK * cascades wipe their data), then recreated. */ import { config } from "dotenv" config({ path: ".env.local" }) // The hardened db layer now defaults to verified TLS. The dev DATABASE_URL is a // plaintext Postgres, so opt back into the original plaintext behaviour for the // seed unless the operator has set DATABASE_SSL explicitly. process.env.DATABASE_SSL = process.env.DATABASE_SSL ?? "disable" import { randomUUID } from "node:crypto" // ── deterministic-ish helpers ──────────────────────────────────────────────── const ymd = (d: Date) => d.toISOString().slice(0, 10) const iso = (d: Date) => d.toISOString() const daysAgo = (n: number) => { const d = new Date() d.setDate(d.getDate() - n) return d } const monthsFromNow = (n: number, day = 1) => { const now = new Date() return new Date(now.getFullYear(), now.getMonth() + n, day) } const pick = (arr: readonly T[], i: number) => arr[i % arr.length] const randInt = (min: number, max: number) => min + Math.floor(Math.random() * (max - min + 1)) // ── data pools ─────────────────────────────────────────────────────────────── const LANDLORDS = [ { name: "Alice Thompson", company: "Thompson Property Group" }, { name: "Brian Okafor", company: "Okafor Rentals" }, { name: "Carla Mendes", company: "Mendes Holdings" }, { name: "Daniel Schwartz", company: "Schwartz & Co Estates" }, { name: "Elena Petrova", company: "Petrova Living" }, { name: "Frank Nguyen", company: "Nguyen Family Homes" }, { name: "Grace Adeyemi", company: "Adeyemi Lettings" }, { name: "Hassan Ali", company: "Ali Property Partners" }, { name: "Isabel Romero", company: "Romero Residential" }, { name: "Jack Sullivan", company: "Sullivan Asset Mgmt" }, ] as const const PLANS = ["starter", "pro", "landlord", "lifetime"] as const const CITIES = [ { city: "Austin", state: "TX", zip: "78701" }, { city: "Denver", state: "CO", zip: "80202" }, { city: "Portland", state: "OR", zip: "97205" }, { city: "Nashville", state: "TN", zip: "37203" }, { city: "Raleigh", state: "NC", zip: "27601" }, { city: "Phoenix", state: "AZ", zip: "85004" }, { city: "Columbus", state: "OH", zip: "43215" }, { city: "Sacramento", state: "CA", zip: "95814" }, ] as const const STREETS = ["Maple St", "Oak Ave", "Riverside Dr", "Crestwood Ln", "Sunset Blvd", "Birch Rd", "Elm Ct", "Hillcrest Way"] const PROP_LABELS = ["Court Apartments", "Flats", "Villa", "Residences", "Lofts", "Terrace", "House", "Commons"] const TENANT_FIRST = ["Sarah", "Marcus", "Priya", "David", "Emily", "James", "Nina", "Omar", "Lucia", "Tom", "Aisha", "Ben", "Mia", "Carlos", "Zoe"] const TENANT_LAST = ["Johnson", "Lee", "Patel", "Kim", "Carter", "Williams", "Novak", "Hassan", "Garcia", "Brooks", "Khan", "Reed", "Chen", "Lopez", "Park"] const MAINT = [ { title: "Leaking kitchen faucet", description: "Faucet drips constantly; water pooling under the sink.", category: "plumbing", priority: "medium" }, { title: "Heating unit not working", description: "Bedroom wall heater stopped working; cold at night.", category: "hvac", priority: "high" }, { title: "Broken window latch", description: "Living room window latch broken; won't close fully.", category: "general", priority: "medium" }, { title: "Garage door motor failure", description: "Opener clicks but the door won't lift.", category: "electrical", priority: "high" }, { title: "Paint peeling in bathroom", description: "Ceiling paint bubbling near the shower; moisture issue.", category: "general", priority: "low" }, { title: "Dishwasher won't drain", description: "Standing water left after every cycle.", category: "appliance", priority: "medium" }, ] as const const EXPENSES = [ { category: "repairs", description: "HVAC inspection and repair", amount: 320 }, { category: "management", description: "Common area deep clean", amount: 250 }, { category: "insurance", description: "Building insurance premium", amount: 1200 }, { category: "utilities", description: "Common area electricity", amount: 145 }, { category: "taxes", description: "Quarterly property tax payment", amount: 2100 }, { category: "supplies", description: "Smoke detectors + batteries", amount: 90 }, ] as const const PAY_METHODS = ["bank_transfer", "card", "stripe", "cash"] // ── main ───────────────────────────────────────────────────────────────────── async function main() { const { db, pool } = await import("../lib/db") const s = await import("../lib/db/schema") const { inArray } = await import("drizzle-orm") const { hashPassword } = await import("better-auth/crypto") const PASSWORD = "Password123!" const emails = LANDLORDS.map((_, i) => `landlord${i + 1}@demo.test`) console.log("Cleaning any previous seed users (cascades to their data)…") await db.delete(s.user).where(inArray(s.user.email, emails)) const summary: { email: string; plan: string; properties: number; tenants: number; payments: number }[] = [] for (let i = 0; i < LANDLORDS.length; i++) { const userId = randomUUID() const email = emails[i] const { name, company } = LANDLORDS[i] const plan = PLANS[i % PLANS.length] const createdAt = daysAgo(randInt(60, 240)) // ── Better Auth: user + hashed-password credential account + app profile ── const passwordHash = await hashPassword(PASSWORD) // node scrypt, salt embedded await db.insert(s.user).values({ id: userId, name, email, emailVerified: true, createdAt, updatedAt: createdAt, }) await db.insert(s.account).values({ id: randomUUID(), accountId: userId, providerId: "credential", userId, password: passwordHash, createdAt, updatedAt: createdAt, }) await db.insert(s.profiles).values({ id: userId, email, full_name: name, company_name: company, plan, onboarding_completed: true, }) const activity: (typeof s.activity_log.$inferInsert)[] = [] let tenantCount = 0 let paymentCount = 0 const city = pick(CITIES, i) const numProps = 1 + (i % 3) // 1..3 properties for (let p = 0; p < numProps; p++) { const propId = randomUUID() const propName = `${pick(STREETS, i + p).split(" ")[0]} ${pick(PROP_LABELS, i + p)}` const numUnits = randInt(1, 3) await db.insert(s.properties).values({ id: propId, user_id: userId, name: propName, address_line1: `${randInt(10, 990)} ${pick(STREETS, i + p)}`, city: city.city, state: city.state, postal_code: city.zip, property_type: "residential", total_units: numUnits, created_at: iso(createdAt), }) activity.push({ user_id: userId, type: "property_added", title: `Added property: ${propName}`, entity_type: "property", entity_id: propId, created_at: iso(daysAgo(randInt(50, 200))), }) for (let u = 0; u < numUnits; u++) { const unitId = randomUUID() const rent = randInt(11, 28) * 100 // 1100..2800 const occupied = Math.random() < 0.8 const tenantId = occupied ? randomUUID() : null await db.insert(s.units).values({ id: unitId, property_id: propId, user_id: userId, unit_number: `${u + 1}${String.fromCharCode(65 + (i % 3))}`, bedrooms: randInt(1, 3), bathrooms: randInt(1, 2), sq_ft: randInt(550, 1300), rent_amount: rent, status: occupied ? "occupied" : "vacant", current_tenant_id: tenantId, }) if (!occupied || !tenantId) continue // Tenant const first = pick(TENANT_FIRST, i * 3 + u + p) const last = pick(TENANT_LAST, i * 2 + u + p) const moveIn = monthsFromNow(-randInt(6, 22), 1) await db.insert(s.tenants).values({ id: tenantId, user_id: userId, property_id: propId, unit_id: unitId, first_name: first, last_name: last, email: `${first.toLowerCase()}.${last.toLowerCase()}${i}${u}@example.com`, phone: `+1 (512) 555-0${randInt(100, 999)}`, move_in_date: ymd(moveIn), status: "active", emergency_contact_name: `${pick(TENANT_FIRST, i + u + 5)} ${last}`, emergency_contact_phone: `+1 (512) 555-0${randInt(100, 999)}`, }) tenantCount++ activity.push({ user_id: userId, type: "tenant_added", title: `New tenant: ${first} ${last}`, entity_type: "tenant", entity_id: tenantId, created_at: iso(daysAgo(randInt(20, 180))), }) // Lease (some expiring within 60 days to trigger alerts) const leaseId = randomUUID() const leaseEnd = monthsFromNow(i % 4 === 0 ? randInt(1, 2) : randInt(8, 16), randInt(1, 28)) await db.insert(s.leases).values({ id: leaseId, user_id: userId, tenant_id: tenantId, property_id: propId, unit_id: unitId, lease_start: ymd(moveIn), lease_end: ymd(leaseEnd), rent_amount: rent, security_deposit: rent * 2, lease_type: "fixed", status: "active", }) activity.push({ user_id: userId, type: "lease_created", title: `Lease signed — Unit ${u + 1}`, entity_type: "lease", entity_id: leaseId, created_at: iso(daysAgo(randInt(20, 180))), }) // 6 months of rent payments for (let m = 5; m >= 0; m--) { const due = monthsFromNow(-m, 1) let status: "paid" | "pending" | "overdue" = "paid" if (m === 0) status = "pending" else if (m === 1 && Math.random() < 0.25) status = "overdue" await db.insert(s.rent_payments).values({ id: randomUUID(), user_id: userId, tenant_id: tenantId, property_id: propId, unit_id: unitId, amount: rent, due_date: ymd(due), paid_date: status === "paid" ? ymd(new Date(due.getFullYear(), due.getMonth(), randInt(1, 4))) : null, status, payment_method: status === "paid" ? pick(PAY_METHODS, m) : null, }) paymentCount++ if (status === "paid") { activity.push({ user_id: userId, type: "rent_paid", title: `Rent paid — ${first} ${last}`, description: `$${rent.toLocaleString()} · ${pick(PAY_METHODS, m)}`, entity_type: "rent", created_at: iso(new Date(due.getFullYear(), due.getMonth(), randInt(1, 4))), }) } } // ~40% of units get a maintenance request if (Math.random() < 0.4) { const mr = pick(MAINT, i + u + p) const mid = randomUUID() const opened = daysAgo(randInt(2, 40)) const resolved = Math.random() < 0.5 await db.insert(s.maintenance_requests).values({ id: mid, user_id: userId, tenant_id: tenantId, property_id: propId, unit_id: unitId, title: mr.title, description: mr.description, category: mr.category as typeof s.maintenance_requests.$inferInsert.category, priority: mr.priority as typeof s.maintenance_requests.$inferInsert.priority, status: resolved ? "resolved" : "open", resolved_at: resolved ? iso(daysAgo(randInt(0, 1))) : null, resolution_notes: resolved ? "Completed by contractor." : null, actual_cost: resolved ? randInt(80, 500) : null, created_at: iso(opened), }) activity.push({ user_id: userId, type: resolved ? "maintenance_resolved" : "maintenance_opened", title: `${resolved ? "Resolved" : "Opened"}: ${mr.title}`, entity_type: "maintenance", entity_id: mid, created_at: iso(opened), }) } } // 2-3 expenses per property const numExp = randInt(2, 3) for (let e = 0; e < numExp; e++) { const ex = pick(EXPENSES, i + p + e) const exDate = daysAgo(randInt(5, 120)) await db.insert(s.expenses).values({ id: randomUUID(), user_id: userId, property_id: propId, category: ex.category as typeof s.expenses.$inferInsert.category, description: `${ex.description} — ${propName}`, amount: ex.amount, expense_date: ymd(exDate), vendor: pick(["HVAC Pro", "CleanCo", "State Farm", "City Energy", "County Tax"], e), is_recurring: ex.category === "insurance" || ex.category === "utilities", }) activity.push({ user_id: userId, type: "expense_added", title: `Expense: ${ex.description}`, description: `$${ex.amount.toLocaleString()} · ${ex.category}`, entity_type: "expense", created_at: iso(exDate), }) } } if (activity.length) await db.insert(s.activity_log).values(activity) summary.push({ email, plan, properties: numProps, tenants: tenantCount, payments: paymentCount }) console.log(` ✓ ${email} [${plan}] ${numProps} properties · ${tenantCount} tenants · ${paymentCount} payments · ${activity.length} activity events`) } console.log("\n────────────────────────────────────────────") console.log("Seed complete. All 10 users can log in with:") console.log(` Password (all accounts): ${PASSWORD}`) console.table(summary) console.log("Passwords are stored hashed (Better Auth node scrypt). No plaintext in the DB.") await pool.end() } main().catch((err) => { console.error("\nSeed failed:", err) process.exit(1) })