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>
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
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",
|
||||
"RESEND_API_KEY",
|
||||
"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) => {
|
||||
const s = v === null || v === undefined ? "" : String(v)
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
||||
}
|
||||
return [headers, ...rows].map((r) => r.map(esc).join(",")).join("\n")
|
||||
}
|
||||
Reference in New Issue
Block a user