Initial import: property management SaaS + security hardening + admin dashboard
Property Management Network — Next.js 16 (App Router), Better Auth, Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend. Includes: - Security hardening: access-control/IDOR fixes, TLS-by-default DB layer, constant-time cron auth, strict security headers, atomic AI quota gating, HTML/email output encoding, demo-backdoor disabled in production. - Superadmin dashboard at /admin (overview/MRR, server-paginated users with ban/impersonate/plan/delete, billing, platform activity + admin audit log, AI usage, system health) via the Better Auth admin plugin. - Seed/migration utility scripts under scripts/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Idempotent migration for the admin dashboard: adds the Better Auth admin
|
||||
* plugin columns (user.role/banned/ban_reason/ban_expires, session.impersonated_by)
|
||||
* and the admin_audit_log table. Safe to run multiple times.
|
||||
*
|
||||
* Run: npx tsx scripts/migrate-admin.ts
|
||||
*/
|
||||
import { config } from "dotenv"
|
||||
config({ path: ".env.local" })
|
||||
process.env.DATABASE_SSL = process.env.DATABASE_SSL ?? "disable"
|
||||
|
||||
const SQL = `
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "role" text DEFAULT 'user';
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "banned" boolean DEFAULT false;
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "ban_reason" text;
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "ban_expires" timestamp;
|
||||
ALTER TABLE "session" ADD COLUMN IF NOT EXISTS "impersonated_by" text;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "admin_audit_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"admin_id" text,
|
||||
"action" text NOT NULL,
|
||||
"target_user_id" text,
|
||||
"metadata" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
"ip_address" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "admin_audit_log"
|
||||
ADD CONSTRAINT "admin_audit_log_admin_id_user_id_fk"
|
||||
FOREIGN KEY ("admin_id") REFERENCES "user"("id") ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "admin_audit_log_created_at_idx" ON "admin_audit_log" ("created_at" DESC);
|
||||
CREATE INDEX IF NOT EXISTS "admin_audit_log_target_idx" ON "admin_audit_log" ("target_user_id");
|
||||
`
|
||||
|
||||
async function main() {
|
||||
const { pool } = await import("../lib/db")
|
||||
await pool.query(SQL)
|
||||
const cols = await pool.query(
|
||||
`select table_name, column_name from information_schema.columns
|
||||
where (table_name='user' and column_name in ('role','banned','ban_reason','ban_expires'))
|
||||
or (table_name='session' and column_name='impersonated_by')
|
||||
order by table_name, column_name`
|
||||
)
|
||||
console.log("Applied admin migration. New columns present:")
|
||||
for (const r of cols.rows) console.log(` ${r.table_name}.${r.column_name}`)
|
||||
const t = await pool.query(`select to_regclass('public.admin_audit_log') as t`)
|
||||
console.log(` admin_audit_log table: ${t.rows[0].t ? "OK" : "MISSING"}`)
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Migration failed:", e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
// Production database migration runner.
|
||||
//
|
||||
// Applies the Drizzle SQL migrations in lib/db/migrations using drizzle-orm's
|
||||
// built-in migrator. Runs without drizzle-kit (a devDependency), so it works
|
||||
// inside the slim production image. Invoked by docker-entrypoint.sh on boot
|
||||
// unless RUN_MIGRATIONS_ON_START=false.
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||
import pg from "pg"
|
||||
|
||||
const { Pool } = pg
|
||||
|
||||
const url = process.env.DATABASE_URL
|
||||
if (!url) {
|
||||
console.error("[migrate] DATABASE_URL is not set — aborting.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Mirror lib/db/index.ts TLS policy so migrations connect exactly like the app:
|
||||
// DATABASE_SSL = "disable" -> no TLS (local dev / unix-socket Postgres)
|
||||
// DATABASE_SSL = "no-verify" -> encrypted, unverified (self-signed certs)
|
||||
// unset / "require" / other -> encrypted + verified (optional DATABASE_CA)
|
||||
function resolveSsl() {
|
||||
switch (process.env.DATABASE_SSL) {
|
||||
case "disable":
|
||||
return false
|
||||
case "no-verify":
|
||||
return { rejectUnauthorized: false }
|
||||
default: {
|
||||
const ca = process.env.DATABASE_CA
|
||||
return ca ? { rejectUnauthorized: true, ca } : { rejectUnauthorized: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: url,
|
||||
ssl: resolveSsl(),
|
||||
})
|
||||
|
||||
const db = drizzle(pool)
|
||||
|
||||
const MAX_ATTEMPTS = 10
|
||||
const RETRY_DELAY_MS = 3000
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: "./lib/db/migrations" })
|
||||
console.log("[migrate] Migrations applied successfully.")
|
||||
await pool.end()
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
const isLast = attempt === MAX_ATTEMPTS
|
||||
console.error(`[migrate] Attempt ${attempt}/${MAX_ATTEMPTS} failed: ${err?.message ?? err}`)
|
||||
if (isLast) {
|
||||
await pool.end().catch(() => {})
|
||||
process.exit(1)
|
||||
}
|
||||
// Postgres may still be starting up (common with the bundled compose DB).
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Create (or reset) the platform superadmin: admin@demo.test / Admin123!
|
||||
* Sets user.role = 'admin' so both our requireAdmin() gate and the Better Auth
|
||||
* admin plugin authorize it. Password is hashed (Better Auth node scrypt).
|
||||
*
|
||||
* Run: npx tsx scripts/seed-admin.ts
|
||||
*/
|
||||
import { config } from "dotenv"
|
||||
config({ path: ".env.local" })
|
||||
process.env.DATABASE_SSL = process.env.DATABASE_SSL ?? "disable"
|
||||
|
||||
import { randomUUID } from "node:crypto"
|
||||
|
||||
const EMAIL = "admin@demo.test"
|
||||
const PASSWORD = "Admin123!"
|
||||
|
||||
async function main() {
|
||||
const { db, pool } = await import("../lib/db")
|
||||
const s = await import("../lib/db/schema")
|
||||
const { eq } = await import("drizzle-orm")
|
||||
const { hashPassword } = await import("better-auth/crypto")
|
||||
|
||||
await db.delete(s.user).where(eq(s.user.email, EMAIL))
|
||||
|
||||
const userId = randomUUID()
|
||||
const now = new Date()
|
||||
await db.insert(s.user).values({
|
||||
id: userId,
|
||||
name: "Platform Admin",
|
||||
email: EMAIL,
|
||||
emailVerified: true,
|
||||
role: "admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
await db.insert(s.account).values({
|
||||
id: randomUUID(),
|
||||
accountId: userId,
|
||||
providerId: "credential",
|
||||
userId,
|
||||
password: await hashPassword(PASSWORD),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
await db.insert(s.profiles).values({
|
||||
id: userId,
|
||||
email: EMAIL,
|
||||
full_name: "Platform Admin",
|
||||
plan: "lifetime",
|
||||
onboarding_completed: true,
|
||||
})
|
||||
|
||||
console.log(`✓ Superadmin ready`)
|
||||
console.log(` email: ${EMAIL}`)
|
||||
console.log(` password: ${PASSWORD}`)
|
||||
console.log(` user id: ${userId}`)
|
||||
console.log(` role: admin`)
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("seed-admin failed:", e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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: <userId>`), 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 = <T>(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)
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Exercises the REAL admin-queries against the live DB to prove the admin
|
||||
* dashboard data layer works end-to-end. Run after seed-users + seed-admin.
|
||||
* Run: npx tsx scripts/verify-admin.ts
|
||||
*/
|
||||
import { config } from "dotenv"
|
||||
config({ path: ".env.local" })
|
||||
process.env.DATABASE_SSL = process.env.DATABASE_SSL ?? "disable"
|
||||
|
||||
async function main() {
|
||||
const q = await import("../lib/db/admin-queries")
|
||||
const { pool } = await import("../lib/db")
|
||||
|
||||
const overview = await q.getAdminOverviewStats()
|
||||
const dist = await q.getPlanDistribution()
|
||||
const users = await q.getUsersPage({ page: 1, pageSize: 5 })
|
||||
const ai = await q.getAiUsageAggregates()
|
||||
const sys = await q.getSystemCounts()
|
||||
|
||||
console.log("── Overview ─────────────────────────────")
|
||||
console.log(` total users: ${overview.totalUsers}`)
|
||||
console.log(` paid / free: ${overview.paidUsers} / ${overview.freeUsers}`)
|
||||
console.log(` MRR / ARR: $${overview.mrr} / $${overview.arr}`)
|
||||
console.log(` lifetime revenue: $${overview.lifetimeRevenue}`)
|
||||
console.log(` properties/tenants: ${overview.totalProperties} / ${overview.totalTenants}`)
|
||||
console.log(` AI calls this month:${overview.aiCallsThisMonth}`)
|
||||
console.log(` active (30d): ${overview.activeUsers30d}`)
|
||||
console.log(` plan distribution: ${JSON.stringify(dist)}`)
|
||||
console.log("── Users page (first 5) ─────────────────")
|
||||
console.log(` total matched: ${users.total} (pageCount ${users.pageCount})`)
|
||||
for (const u of users.rows) {
|
||||
console.log(` ${u.email} [${u.plan}] props=${u.propertyCount} tenants=${u.tenantCount} role=${u.role ?? "user"} banned=${u.banned ?? false}`)
|
||||
}
|
||||
console.log("── AI usage ─────────────────────────────")
|
||||
console.log(` this month total: ${ai.totalThisMonth}`)
|
||||
console.log(` by type: ${JSON.stringify(ai.byType)}`)
|
||||
console.log("── System counts ────────────────────────")
|
||||
console.log(` ${JSON.stringify(sys.counts)}`)
|
||||
|
||||
// sanity assertions
|
||||
const ok = overview.totalUsers >= 11 && users.total >= 11
|
||||
console.log(`\n${ok ? "✓ PASS" : "✗ CHECK"} — expected >= 11 users (10 landlords + admin)`)
|
||||
|
||||
await pool.end()
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("verify-admin failed:", e)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user