Files
Leon SerfatyandClaude Opus 4.8 857b9a7811 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>
2026-06-23 20:36:07 -04:00

240 lines
11 KiB
TypeScript

"use client"
import { useState } from "react"
import { toast } from "sonner"
import { formatDistanceToNow } from "date-fns"
import {
Plus, Play, Trash2, ToggleLeft, ToggleRight,
Loader2, Bell, DollarSign, Wrench, FileText, Home, X,
} from "lucide-react"
const RULE_TYPES = [
{ value: "overdue_rent", label: "Overdue Rent Reminder", icon: DollarSign, color: "text-red-400", desc: "Remind tenants when rent is overdue" },
{ value: "maintenance_stale",label: "Stale Maintenance Alert", icon: Wrench, color: "text-orange-400", desc: "Follow up on open maintenance requests" },
{ value: "lease_renewal", label: "Lease Renewal Notice", icon: FileText, color: "text-blue-400", desc: "Alert tenants about expiring leases" },
{ value: "vacant_unit", label: "Vacant Unit Reminder", icon: Home, color: "text-amber-400", desc: "Internal alerts for vacant units" },
]
const typeColors: Record<string, string> = {
overdue_rent: "text-red-400 bg-red-500/10",
maintenance_stale: "text-orange-400 bg-orange-500/10",
lease_renewal: "text-blue-400 bg-blue-500/10",
vacant_unit: "text-amber-400 bg-amber-500/10",
}
export function FollowUpsClient({ rules: initial, logs: initialLogs }: { rules: any[]; logs: any[] }) {
const [rules, setRules] = useState(initial)
const [logs, setLogs] = useState(initialLogs)
const [running, setRunning] = useState(false)
const [showForm, setShowForm] = useState(false)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({ type: "overdue_rent", name: "", trigger_days: 3, message_template: "" })
async function runFollowUps() {
setRunning(true)
try {
const res = await fetch("/api/follow-ups/run", { method: "POST" })
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? "Failed to run"); return }
if (data.sent === 0) {
toast.info("No follow-ups triggered — all rules are up to date")
} else {
toast.success(`${data.sent} follow-up${data.sent !== 1 ? "s" : ""} triggered`)
setLogs((prev) => [...data.results, ...prev].slice(0, 30))
}
// refresh last_run_at
const refreshed = await fetch("/api/follow-ups")
const refreshedData = await refreshed.json()
setRules(refreshedData.rules ?? rules)
} catch {
toast.error("Network error — please try again")
} finally {
setRunning(false)
}
}
async function saveRule() {
if (!form.name.trim()) return
setSaving(true)
try {
const res = await fetch("/api/follow-ups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? "Failed to save"); return }
setRules((prev) => [...prev, data])
setShowForm(false)
setForm({ type: "overdue_rent", name: "", trigger_days: 3, message_template: "" })
toast.success("Rule added")
} catch {
toast.error("Network error")
} finally {
setSaving(false)
}
}
async function toggleRule(id: string, is_active: boolean) {
const res = await fetch(`/api/follow-ups/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_active: !is_active }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error ?? "Failed to update"); return }
setRules((prev) => prev.map((r) => r.id === id ? data : r))
}
async function deleteRule(id: string) {
await fetch(`/api/follow-ups/${id}`, { method: "DELETE" })
setRules((prev) => prev.filter((r) => r.id !== id))
toast.success("Rule deleted")
}
const cls = "w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white placeholder-white/30 outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500 transition"
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Bell className="h-5 w-5 text-indigo-400" />
Automated Follow-ups
</h2>
<p className="text-sm text-white/40 mt-0.5">Set rules and run them manually to trigger follow-up actions</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-1.5 rounded-xl border border-white/10 px-3 py-2 text-xs font-medium text-white/60 hover:text-white transition"
>
<Plus className="h-3.5 w-3.5" /> Add Rule
</button>
<button
onClick={runFollowUps}
disabled={running || rules.filter((r) => r.is_active).length === 0}
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition"
>
{running ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
{running ? "Running…" : "Run Now"}
</button>
</div>
</div>
{/* Add rule form */}
{showForm && (
<div className="rounded-2xl border border-indigo-500/20 bg-[#16161f] p-5 space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-white">New Follow-up Rule</p>
<button onClick={() => setShowForm(false)} className="text-white/30 hover:text-white transition"><X className="h-4 w-4" /></button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-white/40 mb-1 block">Rule Name *</label>
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. 3-day overdue reminder" className={cls} />
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">Type *</label>
<select value={form.type} onChange={(e) => setForm((f) => ({ ...f, type: e.target.value }))} className={cls}>
{RULE_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">
Trigger after{" "}
<input
type="number"
min={1}
max={90}
value={form.trigger_days}
onChange={(e) => setForm((f) => ({ ...f, trigger_days: parseInt(e.target.value) || 1 }))}
className="mx-1 w-14 rounded border border-white/10 bg-white/5 px-2 py-0.5 text-sm text-white text-center outline-none focus:border-indigo-500"
/>
{" "}days
</label>
</div>
<div>
<label className="text-xs text-white/40 mb-1 block">Custom message (optional leave blank for default)</label>
<textarea value={form.message_template} onChange={(e) => setForm((f) => ({ ...f, message_template: e.target.value }))} rows={3} placeholder="Leave blank to use the default message…" className={cls} />
</div>
<div className="flex gap-3 pt-1">
<button onClick={() => setShowForm(false)} className="rounded-xl border border-white/10 px-4 py-2 text-sm text-white/40 hover:text-white transition">Cancel</button>
<button onClick={saveRule} disabled={saving || !form.name.trim()} className="flex-1 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50 transition">
{saving ? "Saving…" : "Save Rule"}
</button>
</div>
</div>
)}
{/* Rules list */}
{rules.length === 0 ? (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] py-14 text-center">
<Bell className="h-8 w-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">No follow-up rules yet</p>
<p className="text-xs text-white/20 mt-1">Add rules to automate reminders for overdue rent, maintenance, and lease renewals</p>
</div>
) : (
<div className="space-y-2">
{rules.map((rule) => {
const typeInfo = RULE_TYPES.find((t) => t.value === rule.type)
const Icon = typeInfo?.icon ?? Bell
return (
<div key={rule.id} className={`rounded-xl border border-white/[0.06] bg-[#16161f] p-4 flex items-center gap-4 ${!rule.is_active ? "opacity-50" : ""}`}>
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${typeColors[rule.type] ?? "text-white/40 bg-white/5"}`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white">{rule.name}</p>
<p className="text-xs text-white/40 mt-0.5">
{typeInfo?.desc} · Triggers after {rule.trigger_days} day{rule.trigger_days !== 1 ? "s" : ""}
{rule.last_run_at && <span className="ml-2 text-white/25">Last run {formatDistanceToNow(new Date(rule.last_run_at), { addSuffix: true })}</span>}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggleRule(rule.id, rule.is_active)} className="p-1.5 text-white/30 hover:text-white transition" title={rule.is_active ? "Disable" : "Enable"}>
{rule.is_active ? <ToggleRight className="h-5 w-5 text-indigo-400" /> : <ToggleLeft className="h-5 w-5" />}
</button>
<button onClick={() => deleteRule(rule.id)} className="p-1.5 text-white/20 hover:text-red-400 transition rounded-lg hover:bg-red-500/10">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})}
</div>
)}
{/* Log */}
{logs.length > 0 && (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="px-5 py-3.5 border-b border-white/[0.06]">
<p className="text-sm font-semibold text-white">Follow-up Log</p>
</div>
<div className="divide-y divide-white/[0.04]">
{logs.map((log) => (
<div key={log.id} className="px-5 py-3.5 flex items-start gap-4">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${typeColors[log.type] ?? "text-white/40 bg-white/5"}`}>
<Bell className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">{log.subject}</p>
{log.recipient_email && (
<p className="text-xs text-white/35 mt-0.5">{log.recipient_name} · {log.recipient_email}</p>
)}
<p className="text-xs text-white/25 mt-1 line-clamp-2">{log.message}</p>
</div>
<p className="shrink-0 text-xs text-white/25 mt-0.5">
{formatDistanceToNow(new Date(log.created_at), { addSuffix: true })}
</p>
</div>
))}
</div>
</div>
)}
</div>
)
}