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,139 @@
|
||||
"use server"
|
||||
|
||||
import { headers } from "next/headers"
|
||||
import { redirect } from "next/navigation"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { z } from "zod"
|
||||
import { getAdminSession } from "@/lib/session"
|
||||
import { logAdminAction } from "@/lib/admin/audit"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db"
|
||||
import { profiles, user as userTable } from "@/lib/db/schema"
|
||||
|
||||
// ── gate ────────────────────────────────────────────────────────────────────
|
||||
// Every server action re-verifies the caller is an admin. NEVER skip — these
|
||||
// mutate any user's data and bypass user_id scoping.
|
||||
async function guard() {
|
||||
const a = await getAdminSession()
|
||||
if (!a) throw new Error("Forbidden")
|
||||
return a
|
||||
}
|
||||
|
||||
const planSchema = z.enum(["starter", "pro", "landlord", "lifetime"])
|
||||
|
||||
// ── change plan ─────────────────────────────────────────────────────────────
|
||||
export async function changeUserPlan(userId: string, plan: string) {
|
||||
const a = await guard()
|
||||
const nextPlan = planSchema.parse(plan)
|
||||
|
||||
const existing = await db.query.profiles.findFirst({ where: eq(profiles.id, userId) })
|
||||
const oldPlan = existing?.plan ?? null
|
||||
|
||||
await db.update(profiles).set({ plan: nextPlan }).where(eq(profiles.id, userId))
|
||||
|
||||
await logAdminAction({
|
||||
adminId: a.user.id,
|
||||
action: "plan_change",
|
||||
targetUserId: userId,
|
||||
metadata: { from: oldPlan, to: nextPlan },
|
||||
})
|
||||
|
||||
revalidatePath(`/admin/users/${userId}`)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ── ban ─────────────────────────────────────────────────────────────────────
|
||||
export async function banUser(userId: string, reason?: string) {
|
||||
const a = await guard()
|
||||
if (userId === a.user.id) throw new Error("You cannot ban yourself")
|
||||
|
||||
await auth.api.banUser({
|
||||
body: { userId, banReason: reason || "Banned by admin" },
|
||||
headers: await headers(),
|
||||
})
|
||||
|
||||
await logAdminAction({
|
||||
adminId: a.user.id,
|
||||
action: "ban",
|
||||
targetUserId: userId,
|
||||
metadata: { reason: reason || "Banned by admin" },
|
||||
})
|
||||
|
||||
revalidatePath(`/admin/users/${userId}`)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ── unban ───────────────────────────────────────────────────────────────────
|
||||
export async function unbanUser(userId: string) {
|
||||
const a = await guard()
|
||||
|
||||
await auth.api.unbanUser({
|
||||
body: { userId },
|
||||
headers: await headers(),
|
||||
})
|
||||
|
||||
await logAdminAction({
|
||||
adminId: a.user.id,
|
||||
action: "unban",
|
||||
targetUserId: userId,
|
||||
})
|
||||
|
||||
revalidatePath(`/admin/users/${userId}`)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ── impersonate ─────────────────────────────────────────────────────────────
|
||||
export async function impersonateUser(userId: string) {
|
||||
const a = await guard()
|
||||
if (userId === a.user.id) throw new Error("You cannot impersonate yourself")
|
||||
|
||||
await auth.api.impersonateUser({
|
||||
body: { userId },
|
||||
headers: await headers(),
|
||||
})
|
||||
|
||||
await logAdminAction({
|
||||
adminId: a.user.id,
|
||||
action: "impersonate",
|
||||
targetUserId: userId,
|
||||
})
|
||||
|
||||
redirect("/dashboard")
|
||||
}
|
||||
|
||||
// ── delete ──────────────────────────────────────────────────────────────────
|
||||
export async function deleteUser(userId: string) {
|
||||
const a = await guard()
|
||||
if (userId === a.user.id) throw new Error("You cannot delete yourself")
|
||||
|
||||
await auth.api.removeUser({
|
||||
body: { userId },
|
||||
headers: await headers(),
|
||||
})
|
||||
|
||||
await logAdminAction({
|
||||
adminId: a.user.id,
|
||||
action: "delete_user",
|
||||
targetUserId: userId,
|
||||
})
|
||||
|
||||
redirect("/admin/users")
|
||||
}
|
||||
|
||||
// ── mark email verified ─────────────────────────────────────────────────────
|
||||
export async function markEmailVerified(userId: string) {
|
||||
const a = await guard()
|
||||
|
||||
await db.update(userTable).set({ emailVerified: true }).where(eq(userTable.id, userId))
|
||||
|
||||
await logAdminAction({
|
||||
adminId: a.user.id,
|
||||
action: "resend_verification",
|
||||
targetUserId: userId,
|
||||
metadata: { markedVerified: true },
|
||||
})
|
||||
|
||||
revalidatePath(`/admin/users/${userId}`)
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use server"
|
||||
|
||||
import { redirect } from "next/navigation"
|
||||
import { headers } from "next/headers"
|
||||
import { APIError } from "better-auth/api"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
|
||||
|
||||
export async function signUp(formData: FormData) {
|
||||
const email = formData.get("email") as string
|
||||
const password = formData.get("password") as string
|
||||
const fullName = formData.get("full_name") as string
|
||||
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: { email, password, name: fullName },
|
||||
headers: await headers(),
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof APIError ? e.message : "Sign up failed"
|
||||
redirect(`/signup?error=${encodeURIComponent(msg)}`)
|
||||
}
|
||||
|
||||
redirect("/dashboard")
|
||||
}
|
||||
|
||||
export async function signIn(formData: FormData) {
|
||||
const email = formData.get("email") as string
|
||||
const password = formData.get("password") as string
|
||||
|
||||
try {
|
||||
await auth.api.signInEmail({
|
||||
body: { email, password },
|
||||
headers: await headers(),
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof APIError ? e.message : "Invalid email or password"
|
||||
redirect(`/login?error=${encodeURIComponent(msg)}`)
|
||||
}
|
||||
|
||||
redirect("/dashboard")
|
||||
}
|
||||
|
||||
export async function signInWithGoogle() {
|
||||
let url: string | undefined
|
||||
try {
|
||||
const res = await auth.api.signInSocial({
|
||||
body: { provider: "google", callbackURL: "/dashboard" },
|
||||
headers: await headers(),
|
||||
})
|
||||
url = res?.url ?? undefined
|
||||
} catch (e) {
|
||||
const msg = e instanceof APIError ? e.message : "Google sign-in failed"
|
||||
redirect(`/login?error=${encodeURIComponent(msg)}`)
|
||||
}
|
||||
|
||||
if (url) redirect(url)
|
||||
redirect("/login?error=google_failed")
|
||||
}
|
||||
|
||||
export async function resetPassword(formData: FormData) {
|
||||
const email = formData.get("email") as string
|
||||
|
||||
try {
|
||||
await auth.api.requestPasswordReset({
|
||||
body: { email, redirectTo: `${APP_URL}/update-password` },
|
||||
headers: await headers(),
|
||||
})
|
||||
} catch {
|
||||
// Always report success so we don't reveal whether an account exists.
|
||||
}
|
||||
|
||||
redirect("/forgot-password?success=email-sent")
|
||||
}
|
||||
|
||||
export async function signOut() {
|
||||
try {
|
||||
await auth.api.signOut({ headers: await headers() })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
export async function updatePassword(formData: FormData) {
|
||||
const password = formData.get("password") as string
|
||||
const token = formData.get("token") as string
|
||||
|
||||
if (!token) {
|
||||
redirect(`/update-password?error=${encodeURIComponent("Reset link is invalid or expired.")}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.api.resetPassword({
|
||||
body: { newPassword: password, token },
|
||||
headers: await headers(),
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof APIError ? e.message : "Could not update password"
|
||||
redirect(`/update-password?error=${encodeURIComponent(msg)}&token=${encodeURIComponent(token)}`)
|
||||
}
|
||||
|
||||
redirect("/login?success=password-updated")
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use server"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import {
|
||||
properties,
|
||||
units,
|
||||
tenants,
|
||||
leases,
|
||||
rent_payments,
|
||||
maintenance_requests,
|
||||
expenses,
|
||||
profiles,
|
||||
} from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export async function seedDemoData() {
|
||||
if (process.env.NODE_ENV === "production") { throw new Error("Demo tools are disabled in production") }
|
||||
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const uid = user.id
|
||||
|
||||
// ── 1. Properties ──────────────────────────────────────────────
|
||||
const props = await db
|
||||
.insert(properties)
|
||||
.values([
|
||||
{
|
||||
user_id: uid, name: "Maple Court Apartments",
|
||||
address_line1: "14 Maple Street", city: "Austin", state: "TX", postal_code: "78701",
|
||||
property_type: "residential", total_units: 3, notes: "3-storey walkup, built 2005",
|
||||
},
|
||||
{
|
||||
user_id: uid, name: "Riverdale Flats",
|
||||
address_line1: "87 Riverside Drive", city: "Austin", state: "TX", postal_code: "78702",
|
||||
property_type: "residential", total_units: 2, notes: "2 ground-floor units",
|
||||
},
|
||||
{
|
||||
user_id: uid, name: "Crestwood Villa",
|
||||
address_line1: "220 Crestwood Lane", city: "Austin", state: "TX", postal_code: "78703",
|
||||
property_type: "residential", total_units: 2, notes: "Detached villa with garden units",
|
||||
},
|
||||
])
|
||||
.returning({ id: properties.id, name: properties.name })
|
||||
|
||||
const [maple, river, crest] = props
|
||||
|
||||
// ── 2. Units ───────────────────────────────────────────────────
|
||||
const unitRows = await db
|
||||
.insert(units)
|
||||
.values([
|
||||
// Maple Court — 3 units
|
||||
{ user_id: uid, property_id: maple.id, unit_number: "1A", bedrooms: 2, bathrooms: 1, sq_ft: 850, rent_amount: 1800, status: "occupied" },
|
||||
{ user_id: uid, property_id: maple.id, unit_number: "1B", bedrooms: 1, bathrooms: 1, sq_ft: 620, rent_amount: 1350, status: "occupied" },
|
||||
{ user_id: uid, property_id: maple.id, unit_number: "2A", bedrooms: 3, bathrooms: 2, sq_ft: 1100, rent_amount: 2400, status: "occupied" },
|
||||
// Riverdale — 2 units
|
||||
{ user_id: uid, property_id: river.id, unit_number: "G1", bedrooms: 2, bathrooms: 1, sq_ft: 780, rent_amount: 1650, status: "occupied" },
|
||||
{ user_id: uid, property_id: river.id, unit_number: "G2", bedrooms: 1, bathrooms: 1, sq_ft: 560, rent_amount: 1200, status: "vacant" },
|
||||
// Crestwood — 2 units
|
||||
{ user_id: uid, property_id: crest.id, unit_number: "A", bedrooms: 3, bathrooms: 2, sq_ft: 1250, rent_amount: 2800, status: "occupied" },
|
||||
{ user_id: uid, property_id: crest.id, unit_number: "B", bedrooms: 2, bathrooms: 1, sq_ft: 900, rent_amount: 2100, status: "occupied" },
|
||||
])
|
||||
.returning({ id: units.id, unit_number: units.unit_number, property_id: units.property_id, rent_amount: units.rent_amount })
|
||||
|
||||
const [u1A, u1B, u2A, uG1, , uA, uB] = unitRows
|
||||
|
||||
// ── 3. Tenants ─────────────────────────────────────────────────
|
||||
const tenantRows = await db
|
||||
.insert(tenants)
|
||||
.values([
|
||||
{
|
||||
user_id: uid, property_id: maple.id, unit_id: u1A.id,
|
||||
first_name: "Sarah", last_name: "Johnson",
|
||||
email: "sarah.johnson@email.com", phone: "+1 (512) 555-0101",
|
||||
move_in_date: "2023-06-01", status: "active",
|
||||
emergency_contact_name: "Mike Johnson", emergency_contact_phone: "+1 (512) 555-0102",
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: maple.id, unit_id: u1B.id,
|
||||
first_name: "Marcus", last_name: "Lee",
|
||||
email: "marcus.lee@email.com", phone: "+1 (512) 555-0201",
|
||||
move_in_date: "2023-09-01", status: "active",
|
||||
emergency_contact_name: "Lisa Lee", emergency_contact_phone: "+1 (512) 555-0202",
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: maple.id, unit_id: u2A.id,
|
||||
first_name: "Priya", last_name: "Patel",
|
||||
email: "priya.patel@email.com", phone: "+1 (512) 555-0301",
|
||||
move_in_date: "2022-12-01", status: "active",
|
||||
emergency_contact_name: "Raj Patel", emergency_contact_phone: "+1 (512) 555-0302",
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: river.id, unit_id: uG1.id,
|
||||
first_name: "David", last_name: "Kim",
|
||||
email: "david.kim@email.com", phone: "+1 (512) 555-0401",
|
||||
move_in_date: "2024-01-15", status: "active",
|
||||
emergency_contact_name: "Jenny Kim", emergency_contact_phone: "+1 (512) 555-0402",
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: crest.id, unit_id: uA.id,
|
||||
first_name: "Emily", last_name: "Carter",
|
||||
email: "emily.carter@email.com", phone: "+1 (512) 555-0501",
|
||||
move_in_date: "2023-03-01", status: "active",
|
||||
emergency_contact_name: "Tom Carter", emergency_contact_phone: "+1 (512) 555-0502",
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: crest.id, unit_id: uB.id,
|
||||
first_name: "James", last_name: "Williams",
|
||||
email: "james.williams@email.com", phone: "+1 (512) 555-0601",
|
||||
move_in_date: "2024-03-01", status: "active",
|
||||
emergency_contact_name: "Anna Williams", emergency_contact_phone: "+1 (512) 555-0602",
|
||||
},
|
||||
])
|
||||
.returning({ id: tenants.id, first_name: tenants.first_name, last_name: tenants.last_name, property_id: tenants.property_id, unit_id: tenants.unit_id })
|
||||
|
||||
const [tSarah, tMarcus, tPriya, tDavid, tEmily, tJames] = tenantRows
|
||||
|
||||
// Update units with current_tenant_id
|
||||
await Promise.all([
|
||||
db.update(units).set({ current_tenant_id: tSarah.id }).where(eq(units.id, u1A.id)),
|
||||
db.update(units).set({ current_tenant_id: tMarcus.id }).where(eq(units.id, u1B.id)),
|
||||
db.update(units).set({ current_tenant_id: tPriya.id }).where(eq(units.id, u2A.id)),
|
||||
db.update(units).set({ current_tenant_id: tDavid.id }).where(eq(units.id, uG1.id)),
|
||||
db.update(units).set({ current_tenant_id: tEmily.id }).where(eq(units.id, uA.id)),
|
||||
db.update(units).set({ current_tenant_id: tJames.id }).where(eq(units.id, uB.id)),
|
||||
])
|
||||
|
||||
// ── 4. Leases ──────────────────────────────────────────────────
|
||||
await db.insert(leases).values([
|
||||
{
|
||||
user_id: uid, tenant_id: tSarah.id, property_id: maple.id, unit_id: u1A.id,
|
||||
lease_start: "2023-06-01", lease_end: "2025-05-31",
|
||||
rent_amount: 1800, security_deposit: 3600, lease_type: "fixed", status: "active",
|
||||
},
|
||||
{
|
||||
user_id: uid, tenant_id: tMarcus.id, property_id: maple.id, unit_id: u1B.id,
|
||||
lease_start: "2023-09-01", lease_end: "2025-08-31",
|
||||
rent_amount: 1350, security_deposit: 2700, lease_type: "fixed", status: "active",
|
||||
},
|
||||
{
|
||||
user_id: uid, tenant_id: tPriya.id, property_id: maple.id, unit_id: u2A.id,
|
||||
lease_start: "2022-12-01", lease_end: "2025-05-15", // expiring soon!
|
||||
rent_amount: 2400, security_deposit: 4800, lease_type: "fixed", status: "active",
|
||||
},
|
||||
{
|
||||
user_id: uid, tenant_id: tDavid.id, property_id: river.id, unit_id: uG1.id,
|
||||
lease_start: "2024-01-15", lease_end: "2026-01-14",
|
||||
rent_amount: 1650, security_deposit: 3300, lease_type: "fixed", status: "active",
|
||||
},
|
||||
{
|
||||
user_id: uid, tenant_id: tEmily.id, property_id: crest.id, unit_id: uA.id,
|
||||
lease_start: "2023-03-01", lease_end: "2025-04-28", // expiring very soon!
|
||||
rent_amount: 2800, security_deposit: 5600, lease_type: "fixed", status: "active",
|
||||
},
|
||||
{
|
||||
user_id: uid, tenant_id: tJames.id, property_id: crest.id, unit_id: uB.id,
|
||||
lease_start: "2024-03-01", lease_end: "2026-02-28",
|
||||
rent_amount: 2100, security_deposit: 4200, lease_type: "fixed", status: "active",
|
||||
},
|
||||
])
|
||||
|
||||
// ── 5. Rent Payments (last 6 months) ──────────────────────────
|
||||
const now = new Date()
|
||||
|
||||
function dueDate(monthsAgo: number, day = 1) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, day)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
function paidDate(monthsAgo: number, day = 3) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, day)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const paymentRows: (typeof rent_payments.$inferInsert)[] = []
|
||||
|
||||
// Sarah — always pays on time (all paid)
|
||||
for (let m = 5; m >= 1; m--) {
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tSarah.id, property_id: maple.id, unit_id: u1A.id,
|
||||
amount: 1800, due_date: dueDate(m), paid_date: paidDate(m), status: "paid", payment_method: "bank_transfer",
|
||||
})
|
||||
}
|
||||
// Sarah — this month pending
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tSarah.id, property_id: maple.id, unit_id: u1A.id,
|
||||
amount: 1800, due_date: dueDate(0), status: "pending",
|
||||
})
|
||||
|
||||
// Marcus — mostly paid, one overdue
|
||||
for (let m = 5; m >= 2; m--) {
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tMarcus.id, property_id: maple.id, unit_id: u1B.id,
|
||||
amount: 1350, due_date: dueDate(m), paid_date: paidDate(m), status: "paid", payment_method: "card",
|
||||
})
|
||||
}
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tMarcus.id, property_id: maple.id, unit_id: u1B.id,
|
||||
amount: 1350, due_date: dueDate(1), status: "overdue",
|
||||
})
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tMarcus.id, property_id: maple.id, unit_id: u1B.id,
|
||||
amount: 1350, due_date: dueDate(0), status: "pending",
|
||||
})
|
||||
|
||||
// Priya — all paid on time
|
||||
for (let m = 5; m >= 1; m--) {
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tPriya.id, property_id: maple.id, unit_id: u2A.id,
|
||||
amount: 2400, due_date: dueDate(m), paid_date: paidDate(m, 2), status: "paid", payment_method: "stripe",
|
||||
})
|
||||
}
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tPriya.id, property_id: maple.id, unit_id: u2A.id,
|
||||
amount: 2400, due_date: dueDate(0), status: "pending",
|
||||
})
|
||||
|
||||
// David — started Jan 2024, 3 paid months
|
||||
for (let m = 3; m >= 1; m--) {
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tDavid.id, property_id: river.id, unit_id: uG1.id,
|
||||
amount: 1650, due_date: dueDate(m), paid_date: paidDate(m), status: "paid", payment_method: "bank_transfer",
|
||||
})
|
||||
}
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tDavid.id, property_id: river.id, unit_id: uG1.id,
|
||||
amount: 1650, due_date: dueDate(0), status: "pending",
|
||||
})
|
||||
|
||||
// Emily — 4 paid
|
||||
for (let m = 5; m >= 2; m--) {
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tEmily.id, property_id: crest.id, unit_id: uA.id,
|
||||
amount: 2800, due_date: dueDate(m), paid_date: paidDate(m), status: "paid", payment_method: "stripe",
|
||||
})
|
||||
}
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tEmily.id, property_id: crest.id, unit_id: uA.id,
|
||||
amount: 2800, due_date: dueDate(1), status: "overdue",
|
||||
})
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tEmily.id, property_id: crest.id, unit_id: uA.id,
|
||||
amount: 2800, due_date: dueDate(0), status: "pending",
|
||||
})
|
||||
|
||||
// James — new tenant, 2 paid
|
||||
for (let m = 2; m >= 1; m--) {
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tJames.id, property_id: crest.id, unit_id: uB.id,
|
||||
amount: 2100, due_date: dueDate(m), paid_date: paidDate(m), status: "paid", payment_method: "card",
|
||||
})
|
||||
}
|
||||
paymentRows.push({
|
||||
user_id: uid, tenant_id: tJames.id, property_id: crest.id, unit_id: uB.id,
|
||||
amount: 2100, due_date: dueDate(0), status: "pending",
|
||||
})
|
||||
|
||||
await db.insert(rent_payments).values(paymentRows)
|
||||
|
||||
// ── 6. Maintenance Requests ────────────────────────────────────
|
||||
const twoWeeksAgo = new Date(now); twoWeeksAgo.setDate(now.getDate() - 14)
|
||||
const oneWeekAgo = new Date(now); oneWeekAgo.setDate(now.getDate() - 7)
|
||||
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1)
|
||||
const threeDaysAgo = new Date(now); threeDaysAgo.setDate(now.getDate() - 3)
|
||||
|
||||
await db.insert(maintenance_requests).values([
|
||||
{
|
||||
user_id: uid, tenant_id: tSarah.id, property_id: maple.id, unit_id: u1A.id,
|
||||
title: "Leaking kitchen faucet",
|
||||
description: "The kitchen faucet has been dripping constantly for the past week. Water is pooling under the sink cabinet.",
|
||||
category: "plumbing", priority: "medium", status: "open",
|
||||
created_at: oneWeekAgo.toISOString(),
|
||||
},
|
||||
{
|
||||
user_id: uid, tenant_id: tMarcus.id, property_id: maple.id, unit_id: u1B.id,
|
||||
title: "Heating unit not working",
|
||||
description: "The wall heater in the bedroom has stopped working completely. It's getting cold at night.",
|
||||
category: "hvac", priority: "high", status: "in_progress",
|
||||
assigned_to: "HVAC Pro Services", estimated_cost: 350,
|
||||
created_at: twoWeeksAgo.toISOString(),
|
||||
},
|
||||
{
|
||||
user_id: uid, tenant_id: tPriya.id, property_id: maple.id, unit_id: u2A.id,
|
||||
title: "Broken window latch — Unit 2A",
|
||||
description: "The latch on the living room window is broken. Window won't close fully and is a security concern.",
|
||||
category: "general", priority: "medium", status: "open",
|
||||
created_at: threeDaysAgo.toISOString(),
|
||||
},
|
||||
{
|
||||
user_id: uid, tenant_id: tEmily.id, property_id: crest.id, unit_id: uA.id,
|
||||
title: "Garage door motor failure",
|
||||
description: "The automatic garage door opener stopped working. The motor makes a clicking noise but door won't lift.",
|
||||
category: "electrical", priority: "high", status: "open",
|
||||
created_at: yesterday.toISOString(),
|
||||
},
|
||||
{
|
||||
user_id: uid, tenant_id: tDavid.id, property_id: river.id, unit_id: uG1.id,
|
||||
title: "Paint peeling in bathroom",
|
||||
description: "Bathroom ceiling paint is bubbling and peeling near the shower area. Likely moisture issue.",
|
||||
category: "general", priority: "low", status: "resolved",
|
||||
assigned_to: "Quick Fix Painters", actual_cost: 180,
|
||||
resolved_at: twoWeeksAgo.toISOString(),
|
||||
resolution_notes: "Repainted bathroom ceiling with moisture-resistant paint.",
|
||||
created_at: new Date(now.getFullYear(), now.getMonth() - 2, 10).toISOString(),
|
||||
},
|
||||
{
|
||||
user_id: uid, tenant_id: tJames.id, property_id: crest.id, unit_id: uB.id,
|
||||
title: "Kitchen exhaust fan noisy",
|
||||
description: "The kitchen exhaust fan makes a loud rattling noise. Sounds like something is loose inside.",
|
||||
category: "general", priority: "low", status: "open",
|
||||
created_at: new Date(now.getFullYear(), now.getMonth(), 5).toISOString(),
|
||||
},
|
||||
])
|
||||
|
||||
// ── 7. Expenses ────────────────────────────────────────────────
|
||||
await db.insert(expenses).values([
|
||||
{
|
||||
user_id: uid, property_id: maple.id, unit_id: u1B.id,
|
||||
category: "repairs", description: "HVAC inspection and repair — Unit 1B",
|
||||
amount: 320, expense_date: twoWeeksAgo.toISOString().slice(0, 10),
|
||||
vendor: "HVAC Pro Services", is_recurring: false,
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: maple.id,
|
||||
category: "management", description: "Common area deep clean — Maple Court",
|
||||
amount: 250, expense_date: new Date(now.getFullYear(), now.getMonth(), 8).toISOString().slice(0, 10),
|
||||
vendor: "CleanPro Austin", is_recurring: true, recurrence: "monthly",
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: river.id,
|
||||
category: "insurance", description: "Building insurance premium — Riverdale Flats",
|
||||
amount: 1200, expense_date: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 10),
|
||||
vendor: "State Farm", is_recurring: true, recurrence: "annual",
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: crest.id, unit_id: uA.id,
|
||||
category: "repairs", description: "Garage door motor replacement",
|
||||
amount: 480, expense_date: new Date(now.getFullYear(), now.getMonth(), 12).toISOString().slice(0, 10),
|
||||
vendor: "Austin Door Co.", is_recurring: false,
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: maple.id,
|
||||
category: "utilities", description: "Common area electricity — March",
|
||||
amount: 145, expense_date: new Date(now.getFullYear(), now.getMonth() - 1, 28).toISOString().slice(0, 10),
|
||||
vendor: "Austin Energy", is_recurring: true, recurrence: "monthly",
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: maple.id,
|
||||
category: "management", description: "Property management software subscription",
|
||||
amount: 29, expense_date: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
|
||||
vendor: "Property Management Network", is_recurring: true, recurrence: "monthly",
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: crest.id,
|
||||
category: "management", description: "Garden landscaping — quarterly",
|
||||
amount: 360, expense_date: new Date(now.getFullYear(), now.getMonth() - 2, 15).toISOString().slice(0, 10),
|
||||
vendor: "Green Thumb Landscaping", is_recurring: true, recurrence: "quarterly",
|
||||
},
|
||||
{
|
||||
user_id: uid, property_id: river.id,
|
||||
category: "taxes", description: "Property tax Q1 payment",
|
||||
amount: 2100, expense_date: new Date(now.getFullYear(), now.getMonth() - 3, 15).toISOString().slice(0, 10),
|
||||
vendor: "Travis County Tax Office", is_recurring: true, recurrence: "quarterly",
|
||||
},
|
||||
])
|
||||
|
||||
redirect("/dashboard")
|
||||
}
|
||||
|
||||
export async function setTestPlan(plan: "pro" | "landlord" | "lifetime" | "starter") {
|
||||
if (process.env.NODE_ENV === "production") { throw new Error("Demo tools are disabled in production") }
|
||||
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
await db.update(profiles).set({ plan }).where(eq(profiles.id, user.id))
|
||||
|
||||
redirect("/settings/demo")
|
||||
}
|
||||
|
||||
export async function clearDemoData() {
|
||||
if (process.env.NODE_ENV === "production") { throw new Error("Demo tools are disabled in production") }
|
||||
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const uid = user.id
|
||||
|
||||
// Delete in dependency order
|
||||
await db.delete(expenses).where(eq(expenses.user_id, uid))
|
||||
await db.delete(maintenance_requests).where(eq(maintenance_requests.user_id, uid))
|
||||
await db.delete(rent_payments).where(eq(rent_payments.user_id, uid))
|
||||
await db.delete(leases).where(eq(leases.user_id, uid))
|
||||
await db.delete(tenants).where(eq(tenants.user_id, uid))
|
||||
await db.delete(units).where(eq(units.user_id, uid))
|
||||
await db.delete(properties).where(eq(properties.user_id, uid))
|
||||
|
||||
redirect("/dashboard")
|
||||
}
|
||||
Reference in New Issue
Block a user