Files
property-management-network/lib/db/schema.ts
T
Leon SerfatyandClaude Opus 4.8 857b9a7811 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>
2026-06-23 20:36:07 -04:00

590 lines
24 KiB
TypeScript

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] }),
}))