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:
Leon Serfaty
2026-06-23 20:36:07 -04:00
co-authored by Claude Opus 4.8
commit 857b9a7811
291 changed files with 38996 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { follow_up_rules } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const body = await request.json()
const allowed = ["name", "trigger_days", "message_template", "is_active"]
const update: Record<string, unknown> = {}
for (const key of allowed) {
if (key in body) update[key] = body[key]
}
const [data] = await db
.update(follow_up_rules)
.set(update)
.where(and(eq(follow_up_rules.id, id), eq(follow_up_rules.user_id, user.id)))
.returning()
if (!data) return NextResponse.json({ error: "Not found" }, { status: 404 })
return NextResponse.json(data)
}
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
await db
.delete(follow_up_rules)
.where(and(eq(follow_up_rules.id, id), eq(follow_up_rules.user_id, user.id)))
return NextResponse.json({ ok: true })
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server"
import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { follow_up_rules, follow_up_log } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const [rules, logs] = await Promise.all([
db
.select()
.from(follow_up_rules)
.where(eq(follow_up_rules.user_id, user.id))
.orderBy(follow_up_rules.created_at),
db
.select()
.from(follow_up_log)
.where(eq(follow_up_log.user_id, user.id))
.orderBy(desc(follow_up_log.created_at))
.limit(30),
])
return NextResponse.json({ rules, logs })
}
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const { type, name, trigger_days, message_template } = body
if (!type || !name) return NextResponse.json({ error: "type and name required" }, { status: 400 })
const [data] = await db
.insert(follow_up_rules)
.values({ user_id: user.id, type, name, trigger_days: trigger_days ?? 3, message_template })
.returning()
return NextResponse.json(data, { status: 201 })
}
+198
View File
@@ -0,0 +1,198 @@
import { NextResponse } from "next/server"
import { and, eq, gte, lte } from "drizzle-orm"
import { db } from "@/lib/db"
import {
follow_up_rules,
follow_up_log,
rent_payments,
maintenance_requests,
leases,
units,
} from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { logActivity } from "@/lib/activity"
import { sendEmail } from "@/lib/email/send"
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const rules = await db
.select()
.from(follow_up_rules)
.where(and(eq(follow_up_rules.user_id, user.id), eq(follow_up_rules.is_active, true)))
if (!rules.length) return NextResponse.json({ sent: 0, results: [] })
const now = new Date()
const followUpsToLog: any[] = []
for (const rule of rules) {
const cutoff = new Date(now)
cutoff.setDate(cutoff.getDate() - rule.trigger_days)
if (rule.type === "overdue_rent") {
const overdue = await db.query.rent_payments.findMany({
where: and(
eq(rent_payments.user_id, user.id),
eq(rent_payments.status, "overdue"),
lte(rent_payments.due_date, cutoff.toISOString().slice(0, 10))
),
columns: { id: true, amount: true, due_date: true },
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
},
})
for (const payment of overdue) {
const tenant = payment.tenant
if (!tenant?.email) continue
const daysOverdue = Math.ceil((now.getTime() - new Date(payment.due_date).getTime()) / (1000 * 60 * 60 * 24))
followUpsToLog.push({
user_id: user.id,
rule_id: rule.id,
type: "overdue_rent",
recipient_name: `${tenant.first_name} ${tenant.last_name}`,
recipient_email: tenant.email,
subject: `Rent Payment Reminder — ${daysOverdue} Days Overdue`,
message: rule.message_template
?? `Dear ${tenant.first_name}, your rent payment of $${Number(payment.amount).toLocaleString()} was due on ${payment.due_date} and is now ${daysOverdue} days overdue. Please make your payment as soon as possible to avoid further action.`,
status: "sent",
})
}
}
if (rule.type === "maintenance_stale") {
const stale = await db.query.maintenance_requests.findMany({
where: and(
eq(maintenance_requests.user_id, user.id),
eq(maintenance_requests.status, "open"),
lte(maintenance_requests.created_at, cutoff.toISOString())
),
columns: { id: true, title: true, priority: true, created_at: true },
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
},
})
for (const req of stale) {
const tenant = req.tenant
const daysOpen = Math.ceil((now.getTime() - new Date(req.created_at).getTime()) / (1000 * 60 * 60 * 24))
followUpsToLog.push({
user_id: user.id,
rule_id: rule.id,
type: "maintenance_stale",
recipient_name: tenant ? `${tenant.first_name} ${tenant.last_name}` : "N/A",
recipient_email: tenant?.email ?? null,
subject: `Maintenance Update: ${req.title}`,
message: rule.message_template
?? `Your maintenance request "${req.title}" has been open for ${daysOpen} days. We are working on resolving this as soon as possible and will update you shortly.`,
status: "sent",
})
}
}
if (rule.type === "lease_renewal") {
const renewalDate = new Date(now)
renewalDate.setDate(renewalDate.getDate() + rule.trigger_days)
const expiring = await db.query.leases.findMany({
where: and(
eq(leases.user_id, user.id),
eq(leases.status, "active"),
lte(leases.lease_end, renewalDate.toISOString().slice(0, 10)),
gte(leases.lease_end, now.toISOString().slice(0, 10))
),
columns: { id: true, lease_end: true, rent_amount: true },
with: {
tenant: { columns: { first_name: true, last_name: true, email: true } },
},
})
for (const lease of expiring) {
const tenant = lease.tenant
if (!tenant?.email) continue
const daysLeft = Math.ceil((new Date(lease.lease_end).getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
followUpsToLog.push({
user_id: user.id,
rule_id: rule.id,
type: "lease_renewal",
recipient_name: `${tenant.first_name} ${tenant.last_name}`,
recipient_email: tenant.email,
subject: `Lease Renewal Notice — Expires in ${daysLeft} Days`,
message: rule.message_template
?? `Dear ${tenant.first_name}, your lease expires on ${lease.lease_end} (${daysLeft} days from now). Please contact us to discuss renewal options and ensure continuity of your tenancy.`,
status: "sent",
})
}
}
if (rule.type === "vacant_unit") {
const vacant = await db.query.units.findMany({
where: and(eq(units.user_id, user.id), eq(units.status, "vacant")),
columns: { id: true, unit_number: true, rent_amount: true },
with: {
property: { columns: { name: true } },
},
})
for (const unit of vacant) {
const property = unit.property
followUpsToLog.push({
user_id: user.id,
rule_id: rule.id,
type: "vacant_unit",
recipient_name: "You",
recipient_email: null,
subject: `Vacant Unit Alert: ${property?.name ?? ""} — Unit ${unit.unit_number}`,
message: rule.message_template
?? `Unit ${unit.unit_number} at ${property?.name ?? "your property"} has been vacant. Consider reviewing your listing or adjusting the rent of $${Number(unit.rent_amount).toLocaleString()}/month to attract tenants faster.`,
status: "sent",
})
}
}
// Update last_run_at
await db
.update(follow_up_rules)
.set({ last_run_at: now.toISOString() })
.where(and(eq(follow_up_rules.id, rule.id), eq(follow_up_rules.user_id, user.id)))
}
// Send actual emails for all follow-ups that have a recipient
for (const log of followUpsToLog) {
if (log.recipient_email) {
try {
await sendEmail({
to: log.recipient_email,
subject: log.subject,
html: `<!DOCTYPE html>
<html>
<body style="font-family:sans-serif;background:#09090b;color:#fff;padding:40px 20px;max-width:560px;margin:0 auto;">
<div style="background:#16161f;border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:32px;">
<p style="font-size:15px;line-height:1.6;color:rgba(255,255,255,0.8);margin:0 0 24px;">${log.message.replace(/\n/g, "<br/>")}</p>
<p style="color:rgba(255,255,255,0.3);font-size:11px;margin:24px 0 0;border-top:1px solid rgba(255,255,255,0.06);padding-top:16px;">
Property Management Network — Automated Follow-up System
</p>
</div>
</body>
</html>`
})
} catch {
log.status = "failed"
}
}
}
if (followUpsToLog.length > 0) {
await db.insert(follow_up_log).values(followUpsToLog)
}
await logActivity({
userId: user.id,
type: "ai_action",
title: `Follow-ups processed: ${followUpsToLog.length} action${followUpsToLog.length !== 1 ? "s" : ""} triggered`,
})
return NextResponse.json({ sent: followUpsToLog.length, results: followUpsToLog })
}