Files
property-management-network/lib/db/admin-queries.ts
T
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00

415 lines
15 KiB
TypeScript

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",
"SMTP_HOST",
"SMTP_USER",
"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) => {
let s = v === null || v === undefined ? "" : String(v)
// Neutralize spreadsheet formula injection: values that begin with a
// formula trigger (= + - @) or a control char (tab/CR) are prefixed with a
// single quote so Excel/Sheets treat them as text, not executable formulas.
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
return [headers, ...rows].map((r) => r.map(esc).join(",")).join("\n")
}