2026-06-23 20:36:07 -04:00
|
|
|
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",
|
2026-07-02 13:42:34 -04:00
|
|
|
"SMTP_HOST",
|
|
|
|
|
"SMTP_USER",
|
2026-06-23 20:36:07 -04:00
|
|
|
"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) => {
|
2026-07-01 13:56:34 -04:00
|
|
|
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}`
|
2026-06-23 20:36:07 -04:00
|
|
|
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
|
|
|
}
|
|
|
|
|
return [headers, ...rows].map((r) => r.map(esc).join(",")).join("\n")
|
|
|
|
|
}
|