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 { const r = await db.select({ c: sql`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 { const r = await db .select({ c: sql`count(*)::int` }) .from(tbl) .where(sql`user_id = ${userId}`) return r[0].c } // ── plan distribution + MRR ──────────────────────────────────────────────────── export async function getPlanDistribution(): Promise> { const rows = await db .select({ plan: profiles.plan, count: sql`count(*)::int` }) .from(profiles) .groupBy(profiles.plan) const dist: Record = { 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) { 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`count(*)::int` }).from(profiles), db.select({ c: sql`count(*)::int` }).from(profiles).where(gte(profiles.created_at, monthStart)), db.select({ c: sql`count(*)::int` }).from(properties), db.select({ c: sql`count(*)::int` }).from(units), db.select({ c: sql`count(*)::int` }).from(tenants), db.select({ c: sql`count(*)::int` }).from(usage_events).where(gte(usage_events.created_at, monthStart)), db .select({ s: sql`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`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 = {} 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`count(*)::int` }) .from(usage_events) .groupBy(usage_events.event_type) .orderBy(desc(sql`count(*)`)), db.select({ c: sql`count(*)::int` }).from(usage_events).where(gte(usage_events.created_at, monthStart)), db .select({ user_id: usage_events.user_id, count: sql`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 = { 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`max(${follow_up_rules.last_run_at})` }) .from(follow_up_rules) return { counts: Object.fromEntries(entries) as Record, 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`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`count(*)::int` }) .from(properties) .where(inArray(properties.user_id, ids)) .groupBy(properties.user_id), db .select({ user_id: tenants.user_id, c: sql`count(*)::int` }) .from(tenants) .where(inArray(tenants.user_id, ids)) .groupBy(tenants.user_id), db .select({ user_id: activity_log.user_id, last: sql`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>["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 = { 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, 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") }