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:
Leon Serfaty
2026-06-23 20:36:07 -04:00
co-authored by Claude Opus 4.8
commit 857b9a7811
291 changed files with 38996 additions and 0 deletions
+409
View File
@@ -0,0 +1,409 @@
import { and, asc, desc, eq, gte, ilike, inArray, or, sql } from "drizzle-orm"
import type { PgTable } from "drizzle-orm/pg-core"
import { db } from "@/lib/db"
import {
profiles,
user,
properties,
units,
tenants,
rent_payments,
maintenance_requests,
leases,
expenses,
usage_events,
activity_log,
admin_audit_log,
follow_up_rules,
} from "@/lib/db/schema"
import { PLAN_PRICES } from "@/lib/stripe/plans"
import type { Plan } from "@/types"
// ── helpers ───────────────────────────────────────────────────────────────────
const PLANS: Plan[] = ["starter", "pro", "landlord", "lifetime"]
function utcMonthStartISO(d = new Date()) {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)).toISOString()
}
/** count(*) over a whole table. */
async function countRows(tbl: PgTable): Promise<number> {
const r = await db.select({ c: sql<number>`count(*)::int` }).from(tbl)
return r[0].c
}
/** count(*) over a table filtered to one owner (every app table has user_id). */
async function countRowsForUser(tbl: PgTable, userId: string): Promise<number> {
const r = await db
.select({ c: sql<number>`count(*)::int` })
.from(tbl)
.where(sql`user_id = ${userId}`)
return r[0].c
}
// ── plan distribution + MRR ────────────────────────────────────────────────────
export async function getPlanDistribution(): Promise<Record<Plan, number>> {
const rows = await db
.select({ plan: profiles.plan, count: sql<number>`count(*)::int` })
.from(profiles)
.groupBy(profiles.plan)
const dist: Record<Plan, number> = { starter: 0, pro: 0, landlord: 0, lifetime: 0 }
for (const r of rows) if (r.plan in dist) dist[r.plan as Plan] = r.count
return dist
}
export function computeMrr(dist: Record<Plan, number>) {
const proAmt = PLAN_PRICES.pro?.amount ?? 29
const landlordAmt = PLAN_PRICES.landlord?.amount ?? 59
const lifetimeAmt = PLAN_PRICES.lifetime?.amount ?? 199
const mrr = dist.pro * proAmt + dist.landlord * landlordAmt
return { mrr, arr: mrr * 12, lifetimeRevenue: dist.lifetime * lifetimeAmt }
}
// ── overview KPIs ───────────────────────────────────────────────────────────────
export async function getAdminOverviewStats() {
const monthStart = utcMonthStartISO()
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000).toISOString()
const [
totalUsersR,
newSignupsR,
totalPropertiesR,
totalUnitsR,
totalTenantsR,
aiR,
rentR,
activeR,
dist,
] = await Promise.all([
db.select({ c: sql<number>`count(*)::int` }).from(profiles),
db.select({ c: sql<number>`count(*)::int` }).from(profiles).where(gte(profiles.created_at, monthStart)),
db.select({ c: sql<number>`count(*)::int` }).from(properties),
db.select({ c: sql<number>`count(*)::int` }).from(units),
db.select({ c: sql<number>`count(*)::int` }).from(tenants),
db.select({ c: sql<number>`count(*)::int` }).from(usage_events).where(gte(usage_events.created_at, monthStart)),
db
.select({ s: sql<number>`coalesce(sum(${rent_payments.amount}), 0)::float` })
.from(rent_payments)
.where(and(eq(rent_payments.status, "paid"), gte(rent_payments.due_date, monthStart.slice(0, 10)))),
db
.select({ c: sql<number>`count(distinct ${activity_log.user_id})::int` })
.from(activity_log)
.where(gte(activity_log.created_at, thirtyDaysAgo)),
getPlanDistribution(),
])
const totalUsers = totalUsersR[0].c
const paidUsers = dist.pro + dist.landlord + dist.lifetime
const { mrr, arr, lifetimeRevenue } = computeMrr(dist)
return {
totalUsers,
newSignupsThisMonth: newSignupsR[0].c,
totalProperties: totalPropertiesR[0].c,
totalUnits: totalUnitsR[0].c,
totalTenants: totalTenantsR[0].c,
aiCallsThisMonth: aiR[0].c,
rentCollectedThisMonth: rentR[0].s ?? 0,
activeUsers30d: activeR[0].c,
paidUsers,
freeUsers: totalUsers - paidUsers,
planDistribution: dist,
mrr,
arr,
lifetimeRevenue,
}
}
// ── signups trend (last N months) ──────────────────────────────────────────────
export async function getSignupsTrend(months = 6) {
const start = new Date()
start.setMonth(start.getMonth() - (months - 1))
const startISO = utcMonthStartISO(start)
const rows = await db
.select({ created_at: profiles.created_at })
.from(profiles)
.where(gte(profiles.created_at, startISO))
const buckets: Record<string, number> = {}
for (let i = months - 1; i >= 0; i--) {
const d = new Date()
d.setMonth(d.getMonth() - i)
buckets[`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`] = 0
}
for (const r of rows) {
const key = String(r.created_at).slice(0, 7)
if (key in buckets) buckets[key]++
}
return Object.entries(buckets).map(([month, count]) => ({
month,
label: new Date(month + "-01").toLocaleDateString("en-US", { month: "short" }),
count,
}))
}
// ── at-risk subscriptions ───────────────────────────────────────────────────────
export async function getAtRiskSubscriptions() {
return db
.select({
id: profiles.id,
email: profiles.email,
full_name: profiles.full_name,
plan: profiles.plan,
subscription_status: profiles.subscription_status,
plan_expires_at: profiles.plan_expires_at,
})
.from(profiles)
.where(inArray(profiles.subscription_status, ["past_due", "unpaid", "incomplete"]))
.limit(100)
}
// ── AI usage aggregates ─────────────────────────────────────────────────────────
export async function getAiUsageAggregates() {
const monthStart = utcMonthStartISO()
const [byType, totalMonth, topRaw] = await Promise.all([
db
.select({ event_type: usage_events.event_type, count: sql<number>`count(*)::int` })
.from(usage_events)
.groupBy(usage_events.event_type)
.orderBy(desc(sql`count(*)`)),
db.select({ c: sql<number>`count(*)::int` }).from(usage_events).where(gte(usage_events.created_at, monthStart)),
db
.select({ user_id: usage_events.user_id, count: sql<number>`count(*)::int` })
.from(usage_events)
.where(gte(usage_events.created_at, monthStart))
.groupBy(usage_events.user_id)
.orderBy(desc(sql`count(*)`))
.limit(10),
])
const ids = topRaw.map((u) => u.user_id)
const emails = ids.length
? await db.select({ id: profiles.id, email: profiles.email }).from(profiles).where(inArray(profiles.id, ids))
: []
const emailMap = Object.fromEntries(emails.map((e) => [e.id, e.email]))
const topUsers = topRaw.map((u) => ({ ...u, email: emailMap[u.user_id] ?? u.user_id }))
return { byType, totalThisMonth: totalMonth[0].c, topUsers }
}
// ── platform-wide activity feed + admin audit log ───────────────────────────────
export async function getPlatformActivity({ limit = 50, offset = 0 } = {}) {
return db
.select({
id: activity_log.id,
type: activity_log.type,
title: activity_log.title,
description: activity_log.description,
created_at: activity_log.created_at,
user_email: profiles.email,
})
.from(activity_log)
.leftJoin(profiles, eq(activity_log.user_id, profiles.id))
.orderBy(desc(activity_log.created_at))
.limit(limit)
.offset(offset)
}
export async function getAdminAuditLog({ limit = 50, offset = 0 } = {}) {
return db
.select({
id: admin_audit_log.id,
action: admin_audit_log.action,
admin_id: admin_audit_log.admin_id,
target_user_id: admin_audit_log.target_user_id,
metadata: admin_audit_log.metadata,
ip_address: admin_audit_log.ip_address,
created_at: admin_audit_log.created_at,
})
.from(admin_audit_log)
.orderBy(desc(admin_audit_log.created_at))
.limit(limit)
.offset(offset)
}
// ── system health ───────────────────────────────────────────────────────────────
export async function getSystemCounts() {
const tables: Record<string, PgTable> = {
properties,
units,
tenants,
leases,
rent_payments,
maintenance_requests,
expenses,
usage_events,
activity_log,
}
const entries = await Promise.all(
Object.entries(tables).map(async ([name, tbl]) => [name, await countRows(tbl)] as const)
)
const cron = await db
.select({ last: sql<string | null>`max(${follow_up_rules.last_run_at})` })
.from(follow_up_rules)
return { counts: Object.fromEntries(entries) as Record<string, number>, cronLastRun: cron[0]?.last ?? null }
}
export function getEnvHealth() {
const keys = [
"DATABASE_URL",
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
"OPENAI_API_KEY",
"RESEND_API_KEY",
"GOOGLE_CLIENT_ID",
"CRON_SECRET",
"NEXT_PUBLIC_APP_URL",
"ADMIN_USER_IDS",
]
return keys.map((key) => ({ key, present: Boolean(process.env[key]) }))
}
// ── users table (server paginated + searchable) ─────────────────────────────────
const USER_SORTS = {
created_at: profiles.created_at,
email: profiles.email,
plan: profiles.plan,
} as const
export type UserSort = keyof typeof USER_SORTS
export async function getUsersPage(opts: {
q?: string
page?: number
pageSize?: number
plan?: string
sort?: string
dir?: "asc" | "desc"
}) {
const page = Math.max(1, opts.page ?? 1)
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 25))
const sortKey: UserSort = (opts.sort && opts.sort in USER_SORTS ? opts.sort : "created_at") as UserSort
const sortCol = USER_SORTS[sortKey]
const order = opts.dir === "asc" ? asc(sortCol) : desc(sortCol)
const conds = []
if (opts.q) {
const like = `%${opts.q}%`
conds.push(or(ilike(profiles.email, like), ilike(profiles.full_name, like)))
}
if (opts.plan && PLANS.includes(opts.plan as Plan)) {
conds.push(eq(profiles.plan, opts.plan as Plan))
}
const where = conds.length ? and(...conds) : undefined
const [totalR, rows] = await Promise.all([
db.select({ c: sql<number>`count(*)::int` }).from(profiles).where(where),
db
.select({
id: profiles.id,
email: profiles.email,
full_name: profiles.full_name,
plan: profiles.plan,
subscription_status: profiles.subscription_status,
created_at: profiles.created_at,
banned: user.banned,
role: user.role,
emailVerified: user.emailVerified,
})
.from(profiles)
.leftJoin(user, eq(profiles.id, user.id))
.where(where)
.orderBy(order)
.limit(pageSize)
.offset((page - 1) * pageSize),
])
const ids = rows.map((r) => r.id)
const [propC, tenC, lastA] = ids.length
? await Promise.all([
db
.select({ user_id: properties.user_id, c: sql<number>`count(*)::int` })
.from(properties)
.where(inArray(properties.user_id, ids))
.groupBy(properties.user_id),
db
.select({ user_id: tenants.user_id, c: sql<number>`count(*)::int` })
.from(tenants)
.where(inArray(tenants.user_id, ids))
.groupBy(tenants.user_id),
db
.select({ user_id: activity_log.user_id, last: sql<string>`max(${activity_log.created_at})` })
.from(activity_log)
.where(inArray(activity_log.user_id, ids))
.groupBy(activity_log.user_id),
])
: [[], [], []]
const pc = Object.fromEntries(propC.map((r) => [r.user_id, r.c]))
const tc = Object.fromEntries(tenC.map((r) => [r.user_id, r.c]))
const la = Object.fromEntries(lastA.map((r) => [r.user_id, r.last]))
return {
rows: rows.map((r) => ({
...r,
propertyCount: pc[r.id] ?? 0,
tenantCount: tc[r.id] ?? 0,
lastActivity: (la[r.id] as string | undefined) ?? null,
})),
total: totalR[0].c,
page,
pageSize,
pageCount: Math.max(1, Math.ceil(totalR[0].c / pageSize)),
}
}
export type AdminUserRow = Awaited<ReturnType<typeof getUsersPage>>["rows"][number]
// ── single user detail ──────────────────────────────────────────────────────────
export async function getUserDetail(id: string) {
const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, id) })
if (!profile) return null
const account = await db.query.user.findFirst({ where: eq(user.id, id) })
const tableFor: Record<string, PgTable> = {
propertyCount: properties,
unitCount: units,
tenantCount: tenants,
leaseCount: leases,
paymentCount: rent_payments,
maintenanceCount: maintenance_requests,
expenseCount: expenses,
aiCount: usage_events,
}
const entries = await Promise.all(
Object.entries(tableFor).map(async ([key, tbl]) => [key, await countRowsForUser(tbl, id)] as const)
)
const recentActivity = await db
.select({
id: activity_log.id,
type: activity_log.type,
title: activity_log.title,
created_at: activity_log.created_at,
})
.from(activity_log)
.where(eq(activity_log.user_id, id))
.orderBy(desc(activity_log.created_at))
.limit(15)
return {
profile,
account: account ?? null,
counts: Object.fromEntries(entries) as Record<keyof typeof tableFor, number>,
recentActivity,
}
}
// ── CSV helpers (shared by admin export routes) ─────────────────────────────────
export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]) {
const esc = (v: string | number | null | undefined) => {
const s = v === null || v === undefined ? "" : String(v)
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
return [headers, ...rows].map((r) => r.map(esc).join(",")).join("\n")
}
+53
View File
@@ -0,0 +1,53 @@
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool, types } from "pg"
import * as schema from "./schema"
// ── pg type parsers ───────────────────────────────────────────────
// Make the driver return the same value shapes the app relied on under
// Supabase/PostgREST, so the ~hundreds of existing read sites keep working:
// numeric -> JS number (was parsed as number by PostgREST)
// date -> "YYYY-MM-DD" string
// timestamp / timestamptz -> ISO 8601 string
types.setTypeParser(1700, (v) => (v === null ? null : parseFloat(v))) // numeric
types.setTypeParser(1082, (v) => v) // date (identity string)
types.setTypeParser(1114, (v) => (v === null ? null : new Date(v + "Z").toISOString())) // timestamp
types.setTypeParser(1184, (v) => (v === null ? null : new Date(v).toISOString())) // timestamptz
const globalForDb = globalThis as unknown as { pool?: Pool }
// ── TLS policy ────────────────────────────────────────────────────
// Production MUST use verified TLS so credentials and tenant data are
// never sent in plaintext over the network. The default below is
// encrypted + certificate-verified. Behavior is controlled explicitly
// via DATABASE_SSL:
// "disable" -> ssl: false (ONLY for local dev / unix-socket Postgres)
// "no-verify" -> encrypted but unverified (self-signed certs)
// "require" / unset / default -> encrypted + verified (recommended)
// When verifying, an optional custom CA can be supplied via DATABASE_CA.
function resolveSsl(): false | { rejectUnauthorized: boolean; ca?: string } {
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 }
}
}
}
export const pool =
globalForDb.pool ??
new Pool({
connectionString: process.env.DATABASE_URL,
ssl: resolveSsl(),
})
if (process.env.NODE_ENV !== "production") globalForDb.pool = pool
export const db = drizzle(pool, { schema })
export { schema }
+373
View File
@@ -0,0 +1,373 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "activity_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"title" text NOT NULL,
"description" text,
"entity_type" text,
"entity_id" uuid,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ai_predictions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"title" text NOT NULL,
"prediction" text NOT NULL,
"confidence" text,
"timeframe" text,
"risk_level" text,
"data" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ai_recommendations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"title" text NOT NULL,
"description" text NOT NULL,
"impact" text,
"priority" text DEFAULT 'medium' NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"action_label" text,
"action_data" jsonb,
"applied_at" timestamp with time zone,
"dismissed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"property_id" uuid NOT NULL,
"tenant_id" uuid,
"name" text NOT NULL,
"file_url" text NOT NULL,
"storage_path" text,
"file_type" text,
"file_size" integer,
"category" text DEFAULT 'other' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "expenses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"property_id" uuid NOT NULL,
"unit_id" uuid,
"category" text NOT NULL,
"description" text NOT NULL,
"amount" numeric(10, 2) NOT NULL,
"expense_date" date NOT NULL,
"vendor" text,
"receipt_url" text,
"is_recurring" boolean DEFAULT false NOT NULL,
"recurrence" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "follow_up_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"rule_id" uuid NOT NULL,
"type" text NOT NULL,
"recipient_name" text,
"recipient_email" text,
"subject" text NOT NULL,
"message" text NOT NULL,
"status" text DEFAULT 'sent' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "follow_up_rules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"name" text NOT NULL,
"trigger_days" integer DEFAULT 3 NOT NULL,
"message_template" text,
"is_active" boolean DEFAULT true NOT NULL,
"last_run_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "inspections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"property_id" uuid NOT NULL,
"unit_id" uuid,
"type" text NOT NULL,
"date" date NOT NULL,
"status" text DEFAULT 'draft' NOT NULL,
"items" jsonb DEFAULT '[]'::jsonb NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "leases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"tenant_id" uuid NOT NULL,
"property_id" uuid NOT NULL,
"unit_id" uuid,
"lease_start" date NOT NULL,
"lease_end" date NOT NULL,
"rent_amount" numeric(10, 2) NOT NULL,
"security_deposit" numeric(10, 2),
"lease_type" text DEFAULT 'fixed' NOT NULL,
"document_url" text,
"status" text DEFAULT 'active' NOT NULL,
"auto_renew" boolean DEFAULT false NOT NULL,
"reminder_60_sent" boolean DEFAULT false NOT NULL,
"reminder_30_sent" boolean DEFAULT false NOT NULL,
"reminder_7_sent" boolean DEFAULT false NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "maintenance_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"tenant_id" uuid,
"property_id" uuid NOT NULL,
"unit_id" uuid,
"title" text NOT NULL,
"description" text NOT NULL,
"category" text DEFAULT 'general' NOT NULL,
"priority" text DEFAULT 'medium' NOT NULL,
"status" text DEFAULT 'open' NOT NULL,
"images" text[] DEFAULT '{}'::text[] NOT NULL,
"assigned_to" text,
"estimated_cost" numeric(10, 2),
"actual_cost" numeric(10, 2),
"resolved_at" timestamp with time zone,
"resolution_notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "notifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"type" text NOT NULL,
"recipient_email" text NOT NULL,
"subject" text NOT NULL,
"status" text DEFAULT 'sent' NOT NULL,
"read" boolean DEFAULT false NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"sent_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "profiles" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"full_name" text,
"avatar_url" text,
"phone" text,
"company_name" text,
"plan" text DEFAULT 'starter' NOT NULL,
"plan_expires_at" timestamp with time zone,
"stripe_customer_id" text,
"stripe_subscription_id" text,
"subscription_status" text,
"trial_ends_at" timestamp with time zone,
"onboarding_completed" boolean DEFAULT false NOT NULL,
"usage_count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "profiles_stripe_customer_id_unique" UNIQUE("stripe_customer_id")
);
--> statement-breakpoint
CREATE TABLE "properties" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"address_line1" text NOT NULL,
"address_line2" text,
"city" text NOT NULL,
"state" text,
"postal_code" text,
"country" text DEFAULT 'US' NOT NULL,
"property_type" text DEFAULT 'residential' NOT NULL,
"total_units" integer DEFAULT 1 NOT NULL,
"image_url" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "rent_payments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"tenant_id" uuid NOT NULL,
"property_id" uuid NOT NULL,
"unit_id" uuid,
"amount" numeric(10, 2) NOT NULL,
"due_date" date NOT NULL,
"paid_date" date,
"status" text DEFAULT 'pending' NOT NULL,
"payment_method" text,
"stripe_payment_link_id" text,
"stripe_payment_intent_id" text,
"reminder_sent_at" timestamp with time zone,
"late_fee_applied" boolean DEFAULT false NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "tenants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"property_id" uuid NOT NULL,
"unit_id" uuid,
"first_name" text NOT NULL,
"last_name" text NOT NULL,
"email" text,
"phone" text,
"emergency_contact_name" text,
"emergency_contact_phone" text,
"move_in_date" date,
"move_out_date" date,
"status" text DEFAULT 'active' NOT NULL,
"portal_token" text DEFAULT gen_random_uuid()::text NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tenants_portal_token_unique" UNIQUE("portal_token")
);
--> statement-breakpoint
CREATE TABLE "units" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"property_id" uuid NOT NULL,
"user_id" text NOT NULL,
"unit_number" text NOT NULL,
"bedrooms" integer DEFAULT 1 NOT NULL,
"bathrooms" numeric(3, 1) DEFAULT 1 NOT NULL,
"sq_ft" integer,
"rent_amount" numeric(10, 2) NOT NULL,
"status" text DEFAULT 'vacant' NOT NULL,
"current_tenant_id" uuid,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "usage_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"event_type" text NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "vendors" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"property_id" uuid,
"name" text NOT NULL,
"trade" text,
"phone" text,
"email" text,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ai_predictions" ADD CONSTRAINT "ai_predictions_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ai_recommendations" ADD CONSTRAINT "ai_recommendations_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "expenses" ADD CONSTRAINT "expenses_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "expenses" ADD CONSTRAINT "expenses_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "expenses" ADD CONSTRAINT "expenses_unit_id_units_id_fk" FOREIGN KEY ("unit_id") REFERENCES "public"."units"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "follow_up_log" ADD CONSTRAINT "follow_up_log_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "follow_up_log" ADD CONSTRAINT "follow_up_log_rule_id_follow_up_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."follow_up_rules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "follow_up_rules" ADD CONSTRAINT "follow_up_rules_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "inspections" ADD CONSTRAINT "inspections_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "inspections" ADD CONSTRAINT "inspections_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "inspections" ADD CONSTRAINT "inspections_unit_id_units_id_fk" FOREIGN KEY ("unit_id") REFERENCES "public"."units"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leases" ADD CONSTRAINT "leases_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leases" ADD CONSTRAINT "leases_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leases" ADD CONSTRAINT "leases_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leases" ADD CONSTRAINT "leases_unit_id_units_id_fk" FOREIGN KEY ("unit_id") REFERENCES "public"."units"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "maintenance_requests" ADD CONSTRAINT "maintenance_requests_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "maintenance_requests" ADD CONSTRAINT "maintenance_requests_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "maintenance_requests" ADD CONSTRAINT "maintenance_requests_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "maintenance_requests" ADD CONSTRAINT "maintenance_requests_unit_id_units_id_fk" FOREIGN KEY ("unit_id") REFERENCES "public"."units"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_id_user_id_fk" FOREIGN KEY ("id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "properties" ADD CONSTRAINT "properties_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "rent_payments" ADD CONSTRAINT "rent_payments_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "rent_payments" ADD CONSTRAINT "rent_payments_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "rent_payments" ADD CONSTRAINT "rent_payments_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "rent_payments" ADD CONSTRAINT "rent_payments_unit_id_units_id_fk" FOREIGN KEY ("unit_id") REFERENCES "public"."units"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_unit_id_units_id_fk" FOREIGN KEY ("unit_id") REFERENCES "public"."units"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "units" ADD CONSTRAINT "units_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "units" ADD CONSTRAINT "units_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "usage_events" ADD CONSTRAINT "usage_events_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vendors" ADD CONSTRAINT "vendors_user_id_profiles_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."profiles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "vendors" ADD CONSTRAINT "vendors_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1782223467789,
"tag": "0000_next_red_skull",
"breakpoints": true
}
]
}
+31
View File
@@ -0,0 +1,31 @@
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties, units, tenants } from "@/lib/db/schema"
/** True if the property belongs to the user. Null/undefined/empty id returns true (FK optional). */
export async function ownsProperty(userId: string, id?: string | null): Promise<boolean> {
if (!id) return true
const row = await db.query.properties.findFirst({
where: and(eq(properties.id, id), eq(properties.user_id, userId)),
columns: { id: true },
})
return !!row
}
export async function ownsUnit(userId: string, id?: string | null): Promise<boolean> {
if (!id) return true
const row = await db.query.units.findFirst({
where: and(eq(units.id, id), eq(units.user_id, userId)),
columns: { id: true },
})
return !!row
}
export async function ownsTenant(userId: string, id?: string | null): Promise<boolean> {
if (!id) return true
const row = await db.query.tenants.findFirst({
where: and(eq(tenants.id, id), eq(tenants.user_id, userId)),
columns: { id: true },
})
return !!row
}
+220
View File
@@ -0,0 +1,220 @@
import { and, desc, eq, gte, inArray, lte, asc } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties, units, rent_payments, maintenance_requests, leases, expenses } from "@/lib/db/schema"
import type { DashboardStats } from "@/types"
export async function getDashboardStats(userId: string): Promise<DashboardStats> {
// Properties + units
const propertyRows = await db
.select({ id: properties.id })
.from(properties)
.where(eq(properties.user_id, userId))
const totalProperties = propertyRows.length
let totalUnits = 0,
occupiedUnits = 0,
vacantUnits = 0
if (totalProperties > 0) {
const unitRows = await db
.select({ status: units.status })
.from(units)
.where(eq(units.user_id, userId))
totalUnits = unitRows.length
occupiedUnits = unitRows.filter((u) => u.status === "occupied").length
vacantUnits = unitRows.filter((u) => u.status === "vacant").length
}
// Rent this month
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10)
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 10)
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10)
const [rentPayments, lastMonthPayments] = await Promise.all([
db
.select({ amount: rent_payments.amount, status: rent_payments.status })
.from(rent_payments)
.where(
and(
eq(rent_payments.user_id, userId),
gte(rent_payments.due_date, monthStart),
lte(rent_payments.due_date, monthEnd)
)
),
db
.select({ amount: rent_payments.amount, status: rent_payments.status })
.from(rent_payments)
.where(
and(
eq(rent_payments.user_id, userId),
gte(rent_payments.due_date, lastMonthStart),
lte(rent_payments.due_date, lastMonthEnd),
eq(rent_payments.status, "paid")
)
),
])
const rentCollectedThisMonth = rentPayments
.filter((p) => p.status === "paid")
.reduce((sum, p) => sum + Number(p.amount), 0)
const rentCollectedLastMonth = lastMonthPayments.reduce((sum, p) => sum + Number(p.amount), 0)
const rentPendingThisMonth = rentPayments
.filter((p) => p.status === "pending")
.reduce((sum, p) => sum + Number(p.amount), 0)
// Overdue rent (all time)
const overduePayments = await db
.select({ amount: rent_payments.amount })
.from(rent_payments)
.where(and(eq(rent_payments.user_id, userId), eq(rent_payments.status, "overdue")))
const rentOverdue = overduePayments.reduce((sum, p) => sum + Number(p.amount), 0)
// Open maintenance
const openMaintenance = await db
.select({ id: maintenance_requests.id })
.from(maintenance_requests)
.where(
and(
eq(maintenance_requests.user_id, userId),
inArray(maintenance_requests.status, ["open", "in_progress"])
)
)
// Expiring leases (within 60 days)
const in60Days = new Date()
in60Days.setDate(in60Days.getDate() + 60)
const expiring = await db
.select({ id: leases.id })
.from(leases)
.where(
and(
eq(leases.user_id, userId),
eq(leases.status, "active"),
lte(leases.lease_end, in60Days.toISOString().slice(0, 10))
)
)
const occupancyRate = totalUnits > 0 ? Math.round((occupiedUnits / totalUnits) * 100) : 0
return {
totalProperties,
totalUnits,
occupiedUnits,
vacantUnits,
occupancyRate,
rentCollectedThisMonth,
rentCollectedLastMonth,
rentPendingThisMonth,
rentOverdue,
openMaintenanceRequests: openMaintenance.length,
expiringLeases: expiring.length,
}
}
export async function getRecentRentPayments(userId: string, limit = 5) {
return db.query.rent_payments.findMany({
where: eq(rent_payments.user_id, userId),
with: {
tenant: { columns: { first_name: true, last_name: true } },
property: { columns: { name: true } },
},
orderBy: desc(rent_payments.created_at),
limit,
})
}
export async function getOpenMaintenanceRequests(userId: string, limit = 5) {
return db.query.maintenance_requests.findMany({
where: and(
eq(maintenance_requests.user_id, userId),
inArray(maintenance_requests.status, ["open", "in_progress"])
),
with: {
property: { columns: { name: true } },
tenant: { columns: { first_name: true, last_name: true } },
},
orderBy: desc(maintenance_requests.created_at),
limit,
})
}
export async function getMonthlyRevenue(userId: string) {
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5)
sixMonthsAgo.setDate(1)
const data = await db
.select({ amount: rent_payments.amount, due_date: rent_payments.due_date })
.from(rent_payments)
.where(
and(
eq(rent_payments.user_id, userId),
eq(rent_payments.status, "paid"),
gte(rent_payments.due_date, sixMonthsAgo.toISOString().slice(0, 10))
)
)
const months: Record<string, number> = {}
for (let i = 5; i >= 0; i--) {
const d = new Date()
d.setMonth(d.getMonth() - i)
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
months[key] = 0
}
data.forEach((p) => {
const key = p.due_date.slice(0, 7)
if (key in months) months[key] += Number(p.amount)
})
return Object.entries(months).map(([month, amount]) => ({
month,
label: new Date(month + "-01").toLocaleDateString("en-US", { month: "short" }),
amount,
}))
}
export async function getExpenseBreakdown(userId: string) {
const sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5)
sixMonthsAgo.setDate(1)
const data = await db
.select({ category: expenses.category, amount: expenses.amount })
.from(expenses)
.where(
and(
eq(expenses.user_id, userId),
gte(expenses.expense_date, sixMonthsAgo.toISOString().slice(0, 10))
)
)
const totals: Record<string, number> = {}
data.forEach((e) => {
totals[e.category] = (totals[e.category] ?? 0) + Number(e.amount)
})
return Object.entries(totals)
.map(([category, amount]) => ({ category, amount }))
.sort((a, b) => b.amount - a.amount)
}
export async function getExpiringLeases(userId: string, limit = 5) {
const in60Days = new Date()
in60Days.setDate(in60Days.getDate() + 60)
return db.query.leases.findMany({
where: and(
eq(leases.user_id, userId),
eq(leases.status, "active"),
lte(leases.lease_end, in60Days.toISOString().slice(0, 10))
),
with: {
tenant: { columns: { first_name: true, last_name: true } },
property: { columns: { name: true } },
},
orderBy: asc(leases.lease_end),
limit,
})
}
+589
View File
@@ -0,0 +1,589 @@
import { sql } from "drizzle-orm"
import { relations } from "drizzle-orm"
import {
pgTable,
uuid,
text,
integer,
numeric,
boolean,
timestamp,
date,
jsonb,
} from "drizzle-orm/pg-core"
// ============================================================
// Helpers
// ============================================================
// timestamptz returned as ISO strings (matches the previous Supabase/PostgREST
// behaviour the app code relies on). `date` columns returned as "YYYY-MM-DD".
const tstz = (name: string) => timestamp(name, { withTimezone: true, mode: "string" })
const createdAt = () => tstz("created_at").notNull().defaultNow()
const updatedAt = () =>
tstz("updated_at")
.notNull()
.defaultNow()
.$onUpdate(() => new Date().toISOString())
// ============================================================
// BETTER AUTH TABLES (managed by Better Auth — field names must
// stay camelCase to match the Better Auth schema model)
// ============================================================
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
// Better Auth `admin` plugin fields (names must match the plugin model).
role: text("role").default("user"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
})
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
// Better Auth `admin` plugin: set when an admin impersonates this user.
impersonatedBy: text("impersonated_by"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
})
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
})
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
})
// ============================================================
// PROFILES (extends Better Auth user)
// ============================================================
export const profiles = pgTable("profiles", {
id: text("id")
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
email: text("email").notNull(),
full_name: text("full_name"),
avatar_url: text("avatar_url"),
phone: text("phone"),
company_name: text("company_name"),
plan: text("plan").$type<"starter" | "pro" | "landlord" | "lifetime">().notNull().default("starter"),
plan_expires_at: tstz("plan_expires_at"),
stripe_customer_id: text("stripe_customer_id").unique(),
stripe_subscription_id: text("stripe_subscription_id"),
subscription_status: text("subscription_status"),
trial_ends_at: tstz("trial_ends_at"),
onboarding_completed: boolean("onboarding_completed").notNull().default(false),
usage_count: integer("usage_count").notNull().default(0),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// PROPERTIES
// ============================================================
export const properties = pgTable("properties", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
name: text("name").notNull(),
address_line1: text("address_line1").notNull(),
address_line2: text("address_line2"),
city: text("city").notNull(),
state: text("state"),
postal_code: text("postal_code"),
country: text("country").notNull().default("US"),
property_type: text("property_type")
.$type<"residential" | "commercial" | "mixed">()
.notNull()
.default("residential"),
total_units: integer("total_units").notNull().default(1),
image_url: text("image_url"),
notes: text("notes"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// UNITS
// ============================================================
export const units = pgTable("units", {
id: uuid("id").primaryKey().defaultRandom(),
property_id: uuid("property_id")
.notNull()
.references(() => properties.id, { onDelete: "cascade" }),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
unit_number: text("unit_number").notNull(),
bedrooms: integer("bedrooms").notNull().default(1),
bathrooms: numeric("bathrooms", { precision: 3, scale: 1 }).$type<number>().notNull().default(sql`1`),
sq_ft: integer("sq_ft"),
rent_amount: numeric("rent_amount", { precision: 10, scale: 2 }).$type<number>().notNull(),
status: text("status")
.$type<"vacant" | "occupied" | "maintenance" | "unavailable">()
.notNull()
.default("vacant"),
current_tenant_id: uuid("current_tenant_id"),
notes: text("notes"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// TENANTS
// ============================================================
export const tenants = pgTable("tenants", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
property_id: uuid("property_id")
.notNull()
.references(() => properties.id, { onDelete: "cascade" }),
unit_id: uuid("unit_id").references(() => units.id, { onDelete: "set null" }),
first_name: text("first_name").notNull(),
last_name: text("last_name").notNull(),
email: text("email"),
phone: text("phone"),
emergency_contact_name: text("emergency_contact_name"),
emergency_contact_phone: text("emergency_contact_phone"),
move_in_date: date("move_in_date", { mode: "string" }),
move_out_date: date("move_out_date", { mode: "string" }),
status: text("status").$type<"active" | "moved_out" | "evicted">().notNull().default("active"),
portal_token: text("portal_token").notNull().unique().default(sql`gen_random_uuid()::text`),
notes: text("notes"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// RENT PAYMENTS
// ============================================================
export const rent_payments = pgTable("rent_payments", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
tenant_id: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
property_id: uuid("property_id")
.notNull()
.references(() => properties.id, { onDelete: "cascade" }),
unit_id: uuid("unit_id").references(() => units.id, { onDelete: "set null" }),
amount: numeric("amount", { precision: 10, scale: 2 }).$type<number>().notNull(),
due_date: date("due_date", { mode: "string" }).notNull(),
paid_date: date("paid_date", { mode: "string" }),
status: text("status")
.$type<"pending" | "paid" | "overdue" | "partial" | "waived">()
.notNull()
.default("pending"),
payment_method: text("payment_method"),
stripe_payment_link_id: text("stripe_payment_link_id"),
stripe_payment_intent_id: text("stripe_payment_intent_id"),
reminder_sent_at: tstz("reminder_sent_at"),
late_fee_applied: boolean("late_fee_applied").notNull().default(false),
notes: text("notes"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// MAINTENANCE REQUESTS
// ============================================================
export const maintenance_requests = pgTable("maintenance_requests", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
tenant_id: uuid("tenant_id").references(() => tenants.id, { onDelete: "set null" }),
property_id: uuid("property_id")
.notNull()
.references(() => properties.id, { onDelete: "cascade" }),
unit_id: uuid("unit_id").references(() => units.id, { onDelete: "set null" }),
title: text("title").notNull(),
description: text("description").notNull(),
category: text("category")
.$type<"plumbing" | "electrical" | "hvac" | "appliance" | "structural" | "pest" | "general">()
.notNull()
.default("general"),
priority: text("priority").$type<"low" | "medium" | "high" | "emergency">().notNull().default("medium"),
status: text("status").$type<"open" | "in_progress" | "resolved" | "closed">().notNull().default("open"),
images: text("images").array().notNull().default(sql`'{}'::text[]`),
assigned_to: text("assigned_to"),
estimated_cost: numeric("estimated_cost", { precision: 10, scale: 2 }).$type<number>(),
actual_cost: numeric("actual_cost", { precision: 10, scale: 2 }).$type<number>(),
resolved_at: tstz("resolved_at"),
resolution_notes: text("resolution_notes"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// LEASES
// ============================================================
export const leases = pgTable("leases", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
tenant_id: uuid("tenant_id")
.notNull()
.references(() => tenants.id, { onDelete: "cascade" }),
property_id: uuid("property_id")
.notNull()
.references(() => properties.id, { onDelete: "cascade" }),
unit_id: uuid("unit_id").references(() => units.id, { onDelete: "set null" }),
lease_start: date("lease_start", { mode: "string" }).notNull(),
lease_end: date("lease_end", { mode: "string" }).notNull(),
rent_amount: numeric("rent_amount", { precision: 10, scale: 2 }).$type<number>().notNull(),
security_deposit: numeric("security_deposit", { precision: 10, scale: 2 }).$type<number>(),
lease_type: text("lease_type").$type<"fixed" | "month_to_month">().notNull().default("fixed"),
document_url: text("document_url"),
status: text("status").$type<"active" | "expired" | "terminated" | "renewed">().notNull().default("active"),
auto_renew: boolean("auto_renew").notNull().default(false),
reminder_60_sent: boolean("reminder_60_sent").notNull().default(false),
reminder_30_sent: boolean("reminder_30_sent").notNull().default(false),
reminder_7_sent: boolean("reminder_7_sent").notNull().default(false),
notes: text("notes"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// EXPENSES
// ============================================================
export const expenses = pgTable("expenses", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
property_id: uuid("property_id")
.notNull()
.references(() => properties.id, { onDelete: "cascade" }),
unit_id: uuid("unit_id").references(() => units.id, { onDelete: "set null" }),
category: text("category")
.$type<"repairs" | "utilities" | "insurance" | "mortgage" | "taxes" | "management" | "supplies" | "other">()
.notNull(),
description: text("description").notNull(),
amount: numeric("amount", { precision: 10, scale: 2 }).$type<number>().notNull(),
expense_date: date("expense_date", { mode: "string" }).notNull(),
vendor: text("vendor"),
receipt_url: text("receipt_url"),
is_recurring: boolean("is_recurring").notNull().default(false),
recurrence: text("recurrence"),
notes: text("notes"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// DOCUMENTS
// ============================================================
export const documents = pgTable("documents", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
property_id: uuid("property_id")
.notNull()
.references(() => properties.id, { onDelete: "cascade" }),
tenant_id: uuid("tenant_id").references(() => tenants.id, { onDelete: "set null" }),
name: text("name").notNull(),
file_url: text("file_url").notNull(),
storage_path: text("storage_path"),
file_type: text("file_type"),
file_size: integer("file_size"),
category: text("category")
.$type<"lease" | "insurance" | "inspection" | "receipt" | "tax" | "other">()
.notNull()
.default("other"),
created_at: createdAt(),
})
// ============================================================
// NOTIFICATIONS
// ============================================================
export const notifications = pgTable("notifications", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
type: text("type").notNull(),
recipient_email: text("recipient_email").notNull(),
subject: text("subject").notNull(),
status: text("status").$type<"sent" | "failed" | "pending">().notNull().default("sent"),
read: boolean("read").notNull().default(false),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
sent_at: tstz("sent_at").notNull().defaultNow(),
})
// ============================================================
// USAGE EVENTS
// ============================================================
export const usage_events = pgTable("usage_events", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
event_type: text("event_type").notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
created_at: createdAt(),
})
// ============================================================
// VENDORS
// ============================================================
export const vendors = pgTable("vendors", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
property_id: uuid("property_id").references(() => properties.id, { onDelete: "set null" }),
name: text("name").notNull(),
trade: text("trade"),
phone: text("phone"),
email: text("email"),
notes: text("notes"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// INSPECTIONS
// ============================================================
export const inspections = pgTable("inspections", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
property_id: uuid("property_id")
.notNull()
.references(() => properties.id, { onDelete: "cascade" }),
unit_id: uuid("unit_id").references(() => units.id, { onDelete: "set null" }),
type: text("type").$type<"move_in" | "move_out" | "routine">().notNull(),
date: date("date", { mode: "string" }).notNull(),
status: text("status").$type<"draft" | "complete">().notNull().default("draft"),
items: jsonb("items").$type<unknown[]>().notNull().default([]),
notes: text("notes"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// AI RECOMMENDATIONS
// ============================================================
export const ai_recommendations = pgTable("ai_recommendations", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
type: text("type").notNull(),
title: text("title").notNull(),
description: text("description").notNull(),
impact: text("impact"),
priority: text("priority").notNull().default("medium"),
status: text("status").notNull().default("pending"),
action_label: text("action_label"),
action_data: jsonb("action_data").$type<Record<string, unknown>>(),
applied_at: tstz("applied_at"),
dismissed_at: tstz("dismissed_at"),
created_at: createdAt(),
updated_at: updatedAt(),
})
// ============================================================
// AI PREDICTIONS (new — referenced in code, was missing from old schema)
// ============================================================
export const ai_predictions = pgTable("ai_predictions", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
type: text("type").notNull(),
title: text("title").notNull(),
prediction: text("prediction").notNull(),
confidence: text("confidence"),
timeframe: text("timeframe"),
risk_level: text("risk_level"),
data: jsonb("data").$type<Record<string, unknown>>(),
created_at: createdAt(),
})
// ============================================================
// FOLLOW-UPS
// ============================================================
export const follow_up_rules = pgTable("follow_up_rules", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
type: text("type").notNull(),
name: text("name").notNull(),
trigger_days: integer("trigger_days").notNull().default(3),
message_template: text("message_template"),
is_active: boolean("is_active").notNull().default(true),
last_run_at: tstz("last_run_at"),
created_at: createdAt(),
updated_at: updatedAt(),
})
export const follow_up_log = pgTable("follow_up_log", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
rule_id: uuid("rule_id")
.notNull()
.references(() => follow_up_rules.id, { onDelete: "cascade" }),
type: text("type").notNull(),
recipient_name: text("recipient_name"),
recipient_email: text("recipient_email"),
subject: text("subject").notNull(),
message: text("message").notNull(),
status: text("status").notNull().default("sent"),
created_at: createdAt(),
})
// ============================================================
// ACTIVITY LOG
// ============================================================
export const activity_log = pgTable("activity_log", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => profiles.id, { onDelete: "cascade" }),
type: text("type").notNull(),
title: text("title").notNull(),
description: text("description"),
entity_type: text("entity_type"),
entity_id: uuid("entity_id"),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
created_at: createdAt(),
})
// ============================================================
// ADMIN AUDIT LOG (records every mutating superadmin action)
// ============================================================
// target_user_id is intentionally NOT a cascading FK — the audit record must
// survive deletion of the affected user. admin_id is set-null on admin removal.
export const admin_audit_log = pgTable("admin_audit_log", {
id: uuid("id").primaryKey().defaultRandom(),
admin_id: text("admin_id").references(() => user.id, { onDelete: "set null" }),
action: text("action").notNull(),
target_user_id: text("target_user_id"),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
ip_address: text("ip_address"),
created_at: createdAt(),
})
// ============================================================
// RELATIONS (for Drizzle relational queries — replace PostgREST embeds)
// ============================================================
export const profilesRelations = relations(profiles, ({ one }) => ({
user: one(user, { fields: [profiles.id], references: [user.id] }),
}))
export const propertiesRelations = relations(properties, ({ many }) => ({
units: many(units),
tenants: many(tenants),
}))
export const unitsRelations = relations(units, ({ one, many }) => ({
property: one(properties, { fields: [units.property_id], references: [properties.id] }),
// unit's current tenant (units.current_tenant_id -> tenants.id)
current_tenant: one(tenants, {
fields: [units.current_tenant_id],
references: [tenants.id],
relationName: "current_tenant",
}),
// tenants whose unit_id points here (inverse of tenants.unit)
tenants: many(tenants, { relationName: "unit_tenants" }),
}))
export const tenantsRelations = relations(tenants, ({ one, many }) => ({
property: one(properties, { fields: [tenants.property_id], references: [properties.id] }),
unit: one(units, {
fields: [tenants.unit_id],
references: [units.id],
relationName: "unit_tenants",
}),
// inverse of units.current_tenant
current_of_units: many(units, { relationName: "current_tenant" }),
leases: many(leases),
rent_payments: many(rent_payments),
}))
export const rent_paymentsRelations = relations(rent_payments, ({ one }) => ({
tenant: one(tenants, { fields: [rent_payments.tenant_id], references: [tenants.id] }),
property: one(properties, { fields: [rent_payments.property_id], references: [properties.id] }),
unit: one(units, { fields: [rent_payments.unit_id], references: [units.id] }),
}))
export const leasesRelations = relations(leases, ({ one }) => ({
tenant: one(tenants, { fields: [leases.tenant_id], references: [tenants.id] }),
property: one(properties, { fields: [leases.property_id], references: [properties.id] }),
unit: one(units, { fields: [leases.unit_id], references: [units.id] }),
}))
export const maintenance_requestsRelations = relations(maintenance_requests, ({ one }) => ({
tenant: one(tenants, { fields: [maintenance_requests.tenant_id], references: [tenants.id] }),
property: one(properties, { fields: [maintenance_requests.property_id], references: [properties.id] }),
unit: one(units, { fields: [maintenance_requests.unit_id], references: [units.id] }),
}))
export const expensesRelations = relations(expenses, ({ one }) => ({
property: one(properties, { fields: [expenses.property_id], references: [properties.id] }),
unit: one(units, { fields: [expenses.unit_id], references: [units.id] }),
}))
export const inspectionsRelations = relations(inspections, ({ one }) => ({
property: one(properties, { fields: [inspections.property_id], references: [properties.id] }),
unit: one(units, { fields: [inspections.unit_id], references: [units.id] }),
}))
export const documentsRelations = relations(documents, ({ one }) => ({
property: one(properties, { fields: [documents.property_id], references: [properties.id] }),
tenant: one(tenants, { fields: [documents.tenant_id], references: [tenants.id] }),
}))
export const vendorsRelations = relations(vendors, ({ one }) => ({
property: one(properties, { fields: [vendors.property_id], references: [properties.id] }),
}))
export const follow_up_logRelations = relations(follow_up_log, ({ one }) => ({
rule: one(follow_up_rules, { fields: [follow_up_log.rule_id], references: [follow_up_rules.id] }),
}))