2026-06-23 20:36:07 -04:00
|
|
|
import { sql } from "drizzle-orm"
|
|
|
|
|
import { relations } from "drizzle-orm"
|
|
|
|
|
import {
|
|
|
|
|
pgTable,
|
|
|
|
|
uuid,
|
|
|
|
|
text,
|
|
|
|
|
integer,
|
|
|
|
|
numeric,
|
|
|
|
|
boolean,
|
|
|
|
|
timestamp,
|
|
|
|
|
date,
|
|
|
|
|
jsonb,
|
2026-07-02 13:42:34 -04:00
|
|
|
doublePrecision,
|
2026-07-03 06:03:27 -04:00
|
|
|
uniqueIndex,
|
2026-06-23 20:36:07 -04:00
|
|
|
} from "drizzle-orm/pg-core"
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// Helpers
|
|
|
|
|
// ============================================================
|
2026-07-02 13:42:34 -04:00
|
|
|
// timestamptz returned as ISO strings (the shape the app code relies on).
|
|
|
|
|
// `date` columns returned as "YYYY-MM-DD".
|
2026-06-23 20:36:07 -04:00
|
|
|
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"),
|
2026-07-02 13:42:34 -04:00
|
|
|
// Which processor owns the active subscription. Stripe fields above and the
|
|
|
|
|
// PayPal id below are mutually exclusive per active subscription.
|
|
|
|
|
paypal_subscription_id: text("paypal_subscription_id"),
|
|
|
|
|
billing_provider: text("billing_provider").$type<"stripe" | "paypal">(),
|
2026-06-23 20:36:07 -04:00
|
|
|
trial_ends_at: tstz("trial_ends_at"),
|
|
|
|
|
onboarding_completed: boolean("onboarding_completed").notNull().default(false),
|
|
|
|
|
usage_count: integer("usage_count").notNull().default(0),
|
2026-07-02 13:42:34 -04:00
|
|
|
// White-label branding (Landlord/Lifetime plans). Applied to the tenant portal.
|
|
|
|
|
brand_name: text("brand_name"),
|
|
|
|
|
brand_logo_url: text("brand_logo_url"),
|
|
|
|
|
brand_color: text("brand_color"),
|
|
|
|
|
hide_powered_by: boolean("hide_powered_by").notNull().default(false),
|
|
|
|
|
// Read-only iCal (ICS) subscription feed token — served at /api/calendar/<token>.ics
|
|
|
|
|
calendar_token: text("calendar_token").unique().default(sql`gen_random_uuid()::text`),
|
2026-06-23 20:36:07 -04:00
|
|
|
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"),
|
2026-07-02 13:42:34 -04:00
|
|
|
// Geocoded from the address on save (OpenStreetMap Nominatim). Null until
|
|
|
|
|
// geocoding succeeds; drives the property map view.
|
|
|
|
|
latitude: doublePrecision("latitude"),
|
|
|
|
|
longitude: doublePrecision("longitude"),
|
2026-06-23 20:36:07 -04:00
|
|
|
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(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
2026-07-02 13:42:34 -04:00
|
|
|
// APP SETTINGS (global key/value — e.g. site maintenance mode)
|
|
|
|
|
// ============================================================
|
|
|
|
|
// A tiny key/value store for runtime-toggled platform settings that must
|
|
|
|
|
// persist and be changeable from the admin dashboard without a redeploy.
|
|
|
|
|
export const app_settings = pgTable("app_settings", {
|
|
|
|
|
key: text("key").primaryKey(),
|
|
|
|
|
value: jsonb("value").$type<Record<string, unknown>>().notNull().default({}),
|
|
|
|
|
updated_at: updatedAt(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// ACCOUNT MEMBERS (team access — Landlord/Lifetime plans)
|
|
|
|
|
// ============================================================
|
|
|
|
|
// Lets an account OWNER invite other users to access their portfolio. A member
|
|
|
|
|
// with status='active' operates under the owner's data (resolved by
|
|
|
|
|
// getEffectiveOwnerId in lib/account.ts). owner_id is the portfolio owner;
|
|
|
|
|
// member_id is set once the invite is accepted.
|
|
|
|
|
export const account_members = pgTable("account_members", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
owner_id: text("owner_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => profiles.id, { onDelete: "cascade" }),
|
|
|
|
|
member_id: text("member_id").references(() => profiles.id, { onDelete: "cascade" }),
|
|
|
|
|
email: text("email").notNull(),
|
|
|
|
|
role: text("role").$type<"member" | "viewer">().notNull().default("member"),
|
|
|
|
|
status: text("status").$type<"pending" | "active" | "revoked">().notNull().default("pending"),
|
|
|
|
|
invite_token: text("invite_token").notNull().unique().default(sql`gen_random_uuid()::text`),
|
|
|
|
|
accepted_at: tstz("accepted_at"),
|
|
|
|
|
created_at: createdAt(),
|
|
|
|
|
updated_at: updatedAt(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// API KEYS (public REST API — Bearer auth for /api/v1)
|
|
|
|
|
// ============================================================
|
|
|
|
|
// Each key belongs to a user. We store ONLY a SHA-256 hash of the secret; the
|
|
|
|
|
// plaintext is shown once at creation and never persisted. `key_prefix` is a
|
|
|
|
|
// short, non-secret identifier for display in the dashboard.
|
|
|
|
|
export const api_keys = pgTable("api_keys", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
user_id: text("user_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => profiles.id, { onDelete: "cascade" }),
|
|
|
|
|
name: text("name").notNull(),
|
|
|
|
|
key_hash: text("key_hash").notNull().unique(),
|
|
|
|
|
key_prefix: text("key_prefix").notNull(),
|
|
|
|
|
last_used_at: tstz("last_used_at"),
|
|
|
|
|
revoked_at: tstz("revoked_at"),
|
|
|
|
|
created_at: createdAt(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// ACCOUNTING CONNECTIONS (QuickBooks / Xero OAuth sync)
|
|
|
|
|
// ============================================================
|
|
|
|
|
// One row per (owner, provider). OAuth tokens are stored AES-256-GCM encrypted
|
|
|
|
|
// (see lib/crypto.ts). A landlord connects their books and rent income +
|
|
|
|
|
// expenses are pushed one-way into QuickBooks Online or Xero.
|
|
|
|
|
export const accounting_connections = pgTable("accounting_connections", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
user_id: text("user_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => profiles.id, { onDelete: "cascade" }),
|
|
|
|
|
provider: text("provider").$type<"quickbooks" | "xero">().notNull(),
|
|
|
|
|
access_token: text("access_token").notNull(), // encrypted
|
|
|
|
|
refresh_token: text("refresh_token").notNull(), // encrypted
|
|
|
|
|
expires_at: tstz("expires_at"),
|
|
|
|
|
// Provider account id: QuickBooks realmId / Xero tenantId.
|
|
|
|
|
realm_id: text("realm_id"),
|
|
|
|
|
org_name: text("org_name"),
|
|
|
|
|
status: text("status").$type<"active" | "error" | "revoked">().notNull().default("active"),
|
|
|
|
|
last_sync_at: tstz("last_sync_at"),
|
|
|
|
|
last_error: text("last_error"),
|
|
|
|
|
created_at: createdAt(),
|
|
|
|
|
updated_at: updatedAt(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// SIGNATURE REQUESTS (e-signature — DocuSign / Dropbox Sign)
|
|
|
|
|
// ============================================================
|
|
|
|
|
// Tracks a lease document sent out for e-signature. `external_id` is the
|
|
|
|
|
// provider's envelope / signature_request id; the webhook flips status to
|
|
|
|
|
// "signed" and records the completed document.
|
|
|
|
|
export const signature_requests = pgTable("signature_requests", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
user_id: text("user_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => profiles.id, { onDelete: "cascade" }),
|
|
|
|
|
lease_id: uuid("lease_id").references(() => leases.id, { onDelete: "set null" }),
|
|
|
|
|
provider: text("provider").$type<"docusign" | "dropbox_sign">().notNull(),
|
|
|
|
|
external_id: text("external_id"),
|
|
|
|
|
status: text("status")
|
|
|
|
|
.$type<"sent" | "signed" | "declined" | "voided" | "error">()
|
|
|
|
|
.notNull()
|
|
|
|
|
.default("sent"),
|
|
|
|
|
signer_email: text("signer_email").notNull(),
|
|
|
|
|
signer_name: text("signer_name"),
|
|
|
|
|
document_name: text("document_name"),
|
|
|
|
|
signed_document_url: text("signed_document_url"),
|
|
|
|
|
last_error: text("last_error"),
|
|
|
|
|
sent_at: createdAt(),
|
|
|
|
|
completed_at: tstz("completed_at"),
|
|
|
|
|
updated_at: updatedAt(),
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
// ============================================================
|
|
|
|
|
// E-SIGN CONNECTIONS (per-landlord DocuSign OAuth / Dropbox Sign API key)
|
|
|
|
|
// ============================================================
|
|
|
|
|
// One row per (owner, provider). Each landlord connects THEIR OWN e-signature
|
|
|
|
|
// account, so leases are sent from their brand with their audit trail. DocuSign
|
|
|
|
|
// uses OAuth (access + refresh tokens); Dropbox Sign uses an API key stored in
|
|
|
|
|
// `access_token`. All secrets are AES-256-GCM encrypted (see lib/crypto.ts).
|
|
|
|
|
export const esign_connections = pgTable("esign_connections", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
user_id: text("user_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => profiles.id, { onDelete: "cascade" }),
|
|
|
|
|
provider: text("provider").$type<"docusign" | "dropbox_sign">().notNull(),
|
|
|
|
|
access_token: text("access_token").notNull(), // encrypted (DocuSign access token / Dropbox Sign API key)
|
|
|
|
|
refresh_token: text("refresh_token"), // encrypted (DocuSign only)
|
|
|
|
|
expires_at: tstz("expires_at"),
|
|
|
|
|
// DocuSign account id + base uri from /oauth/userinfo (null for Dropbox Sign).
|
|
|
|
|
account_id: text("account_id"),
|
|
|
|
|
base_uri: text("base_uri"),
|
|
|
|
|
account_name: text("account_name"),
|
|
|
|
|
status: text("status").$type<"active" | "error" | "revoked">().notNull().default("active"),
|
|
|
|
|
last_error: text("last_error"),
|
|
|
|
|
created_at: createdAt(),
|
|
|
|
|
updated_at: updatedAt(),
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-02 13:42:34 -04:00
|
|
|
// ============================================================
|
|
|
|
|
// WEBHOOK ENDPOINTS (outbound webhooks / Zapier integration)
|
|
|
|
|
// ============================================================
|
|
|
|
|
// A landlord registers HTTPS endpoints that receive a signed JSON POST every
|
|
|
|
|
// time a subscribed event occurs (e.g. tenant.created, payment.paid). Scoped by
|
|
|
|
|
// the account owner id so every event in the portfolio is delivered. The
|
|
|
|
|
// `secret` is the HMAC-SHA256 signing key surfaced in the dashboard so the
|
|
|
|
|
// receiver can verify the `X-PMN-Signature` header. `events` is the set of
|
|
|
|
|
// subscribed event ids; an empty array means "all events". `source` records who
|
|
|
|
|
// created it (dashboard, the REST API, or a Zapier REST-hook subscription).
|
|
|
|
|
export const webhook_endpoints = pgTable("webhook_endpoints", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
user_id: text("user_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => profiles.id, { onDelete: "cascade" }),
|
|
|
|
|
url: text("url").notNull(),
|
|
|
|
|
description: text("description"),
|
|
|
|
|
events: text("events").array().notNull().default(sql`'{}'::text[]`),
|
|
|
|
|
secret: text("secret").notNull(),
|
|
|
|
|
status: text("status").$type<"active" | "disabled">().notNull().default("active"),
|
|
|
|
|
source: text("source").$type<"dashboard" | "api" | "zapier">().notNull().default("dashboard"),
|
|
|
|
|
last_success_at: tstz("last_success_at"),
|
|
|
|
|
last_error_at: tstz("last_error_at"),
|
|
|
|
|
last_error: text("last_error"),
|
|
|
|
|
// Consecutive delivery failures; reset to 0 on any success.
|
|
|
|
|
failure_count: integer("failure_count").notNull().default(0),
|
|
|
|
|
created_at: createdAt(),
|
|
|
|
|
updated_at: updatedAt(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// WEBHOOK DELIVERIES (per-endpoint delivery log + retry queue)
|
|
|
|
|
// ============================================================
|
|
|
|
|
// One row per (event, endpoint). Created "pending"; the emitter attempts an
|
|
|
|
|
// immediate delivery and the webhooks cron retries anything still pending/failed
|
|
|
|
|
// with exponential backoff until max_attempts is reached.
|
|
|
|
|
export const webhook_deliveries = pgTable("webhook_deliveries", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
user_id: text("user_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => profiles.id, { onDelete: "cascade" }),
|
|
|
|
|
endpoint_id: uuid("endpoint_id")
|
|
|
|
|
.notNull()
|
|
|
|
|
.references(() => webhook_endpoints.id, { onDelete: "cascade" }),
|
|
|
|
|
event: text("event").notNull(),
|
|
|
|
|
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
|
|
|
|
status: text("status").$type<"pending" | "success" | "failed">().notNull().default("pending"),
|
|
|
|
|
attempts: integer("attempts").notNull().default(0),
|
|
|
|
|
max_attempts: integer("max_attempts").notNull().default(5),
|
|
|
|
|
next_attempt_at: tstz("next_attempt_at").notNull().defaultNow(),
|
|
|
|
|
response_status: integer("response_status"),
|
|
|
|
|
response_body: text("response_body"),
|
|
|
|
|
error: text("error"),
|
|
|
|
|
delivered_at: tstz("delivered_at"),
|
|
|
|
|
created_at: createdAt(),
|
|
|
|
|
updated_at: updatedAt(),
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-03 06:03:27 -04:00
|
|
|
// ============================================================
|
|
|
|
|
// ACCOUNT DELETION REQUESTS (GDPR right to erasure)
|
|
|
|
|
// ============================================================
|
|
|
|
|
// A user's self-service "delete my account" request. Deletion is deferred by a
|
|
|
|
|
// grace period (LEGAL.dataDeletionDays) during which the user can cancel; the
|
|
|
|
|
// gdpr cron then hard-deletes the account, its data, and its stored files.
|
|
|
|
|
// user_id is intentionally NOT a cascading FK — the completed request must
|
|
|
|
|
// survive the user's deletion as evidence the DSAR was honored. `email` is
|
|
|
|
|
// kept only while the request is pending (to notify) and nulled on completion.
|
|
|
|
|
export const account_deletion_requests = pgTable(
|
|
|
|
|
"account_deletion_requests",
|
|
|
|
|
{
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
user_id: text("user_id").notNull(),
|
|
|
|
|
email: text("email"),
|
|
|
|
|
status: text("status").$type<"pending" | "cancelled" | "completed">().notNull().default("pending"),
|
|
|
|
|
reason: text("reason"),
|
|
|
|
|
scheduled_for: tstz("scheduled_for").notNull(),
|
|
|
|
|
cancelled_at: tstz("cancelled_at"),
|
|
|
|
|
completed_at: tstz("completed_at"),
|
|
|
|
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
|
|
|
|
created_at: createdAt(),
|
|
|
|
|
updated_at: updatedAt(),
|
|
|
|
|
},
|
|
|
|
|
(t) => [
|
|
|
|
|
// At most ONE open request per user — the request/cancel flow relies on this.
|
|
|
|
|
uniqueIndex("account_deletion_requests_pending_user_idx")
|
|
|
|
|
.on(t.user_id)
|
|
|
|
|
.where(sql`status = 'pending'`),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// CONSENT LOG (GDPR proof of consent / acceptance)
|
|
|
|
|
// ============================================================
|
|
|
|
|
// Records when a person accepted the Terms/Privacy Policy (at signup) or made a
|
|
|
|
|
// cookie/marketing consent choice. user_id has no FK so the record survives
|
|
|
|
|
// account deletion as compliance evidence; identifying fields (email, ip) are
|
|
|
|
|
// anonymized by the deletion flow.
|
|
|
|
|
export const consent_log = pgTable("consent_log", {
|
|
|
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
|
|
|
user_id: text("user_id"),
|
|
|
|
|
email: text("email"),
|
|
|
|
|
kind: text("kind").$type<"terms" | "privacy" | "cookies" | "marketing">().notNull(),
|
|
|
|
|
granted: boolean("granted").notNull(),
|
|
|
|
|
policy_version: text("policy_version"),
|
|
|
|
|
source: text("source"),
|
|
|
|
|
ip_address: text("ip_address"),
|
|
|
|
|
user_agent: text("user_agent"),
|
|
|
|
|
created_at: createdAt(),
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-02 13:42:34 -04:00
|
|
|
// ============================================================
|
|
|
|
|
// RELATIONS (for Drizzle relational queries)
|
2026-06-23 20:36:07 -04:00
|
|
|
// ============================================================
|
|
|
|
|
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] }),
|
|
|
|
|
}))
|
2026-07-02 13:42:34 -04:00
|
|
|
|
|
|
|
|
export const webhook_endpointsRelations = relations(webhook_endpoints, ({ many }) => ({
|
|
|
|
|
deliveries: many(webhook_deliveries),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
export const webhook_deliveriesRelations = relations(webhook_deliveries, ({ one }) => ({
|
|
|
|
|
endpoint: one(webhook_endpoints, {
|
|
|
|
|
fields: [webhook_deliveries.endpoint_id],
|
|
|
|
|
references: [webhook_endpoints.id],
|
|
|
|
|
}),
|
|
|
|
|
}))
|