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,307 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Send, Bot, Sparkles, Lock, Loader2, RotateCcw, Copy, Check, Zap } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import type { Plan } from "@/types"
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
const SUGGESTED = [
|
||||
"Which tenants have overdue rent this month?",
|
||||
"Summarise my open maintenance requests",
|
||||
"How is my occupancy rate?",
|
||||
"Which leases are expiring in 60 days?",
|
||||
"What were my total expenses this quarter?",
|
||||
"Which property earns the most rent?",
|
||||
]
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-lg px-2 py-1 text-[10px] text-white/25 hover:text-white/60 hover:bg-white/[0.04] transition"
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? <><Check className="h-3 w-3" /> Copied</> : <><Copy className="h-3 w-3" /> Copy</>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }: { msg: Message }) {
|
||||
if (msg.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-indigo-600 px-4 py-3 shadow-lg shadow-indigo-500/10">
|
||||
<p className="text-sm text-white leading-relaxed">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-md shadow-indigo-500/20 mt-0.5">
|
||||
<Bot className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`rounded-2xl rounded-tl-sm px-4 py-3 ${
|
||||
msg.error
|
||||
? "bg-red-500/8 border border-red-500/20 text-red-300"
|
||||
: "bg-[#1d1d2a] border border-white/[0.06] text-white/85"
|
||||
}`}>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
{!msg.error && (
|
||||
<div className="mt-1 pl-1">
|
||||
<CopyButton text={msg.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AiChatProps {
|
||||
plan: Plan
|
||||
limit: number
|
||||
used: number
|
||||
}
|
||||
|
||||
export function AiChat({ plan, limit, used }: AiChatProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentUsed, setCurrentUsed] = useState(used)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const isLocked = limit === 0
|
||||
const isExhausted = !isLocked && currentUsed >= limit
|
||||
const usagePct = limit > 0 ? Math.min((currentUsed / limit) * 100, 100) : 0
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages, loading])
|
||||
|
||||
async function send(question: string) {
|
||||
if (!question.trim() || loading || isLocked || isExhausted) return
|
||||
|
||||
setMessages((prev) => [...prev, { role: "user", content: question }])
|
||||
setInput("")
|
||||
setLoading(true)
|
||||
|
||||
// Reset textarea height
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = "auto"
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai/ask", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: data.error ?? "Something went wrong.", error: true }])
|
||||
} else {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: data.answer }])
|
||||
if (data.usage) setCurrentUsed(data.usage.used)
|
||||
}
|
||||
} catch {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: "Network error. Please try again.", error: true }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKey(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send(input)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-64px-2rem)] max-w-3xl mx-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-500/20">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white">AI Assistant</h2>
|
||||
<p className="text-xs text-white/35">Powered by your live portfolio data</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{!isLocked && (
|
||||
<div className="hidden sm:flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-3 w-3 text-white/30" />
|
||||
<span className={`text-xs font-medium tabular-nums ${isExhausted ? "text-red-400" : "text-white/50"}`}>
|
||||
{currentUsed} / {limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-20 h-1 rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className={`h-1 rounded-full transition-all ${isExhausted ? "bg-red-500" : usagePct > 75 ? "bg-amber-500" : "bg-indigo-500"}`}
|
||||
style={{ width: `${usagePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => setMessages([])}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-white/[0.06] px-3 py-1.5 text-xs text-white/35 hover:text-white hover:border-white/20 transition"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" /> Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Locked state */}
|
||||
{isLocked ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="relative text-center max-w-sm px-4">
|
||||
<div className="absolute inset-0 rounded-3xl bg-indigo-500/5 blur-3xl -z-10" />
|
||||
<div className="relative">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500/15 to-violet-500/10 border border-indigo-500/20 mx-auto mb-5 shadow-xl shadow-indigo-500/5">
|
||||
<Lock className="h-9 w-9 text-indigo-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">AI requires Pro plan</h3>
|
||||
<p className="text-sm text-white/45 mb-8 leading-relaxed">
|
||||
Upgrade to unlock AI-powered insights about your properties, tenants, rent collection, and more.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-6 py-3 text-sm font-semibold text-white hover:bg-indigo-500 transition-all hover:shadow-xl hover:shadow-indigo-500/25"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Upgrade to Pro — 50 AI calls/mo
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings/demo"
|
||||
className="block text-xs text-white/30 hover:text-white/60 transition"
|
||||
>
|
||||
Testing? Switch plan in Demo Data →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat area */}
|
||||
<div className="flex-1 overflow-y-auto space-y-5 pr-1 pb-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-7 text-center">
|
||||
<div>
|
||||
<div className="relative mx-auto mb-5 h-20 w-20">
|
||||
<div className="absolute inset-0 rounded-2xl bg-indigo-500/10 blur-xl" />
|
||||
<div className="relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-indigo-500/20 to-violet-500/10 border border-indigo-500/20">
|
||||
<Bot className="h-9 w-9 text-indigo-300" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">How can I help?</h3>
|
||||
<p className="text-sm text-white/40 max-w-xs leading-relaxed">
|
||||
I have full access to your live portfolio — properties, tenants, payments, maintenance, and leases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-lg">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/25 mb-3">Try asking</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{SUGGESTED.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => send(q)}
|
||||
className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-4 py-3 text-left text-xs text-white/55 hover:border-indigo-500/30 hover:bg-indigo-500/5 hover:text-white/90 transition-all duration-150"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} />
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 mt-0.5">
|
||||
<Bot className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="rounded-2xl rounded-tl-sm bg-[#1d1d2a] border border-white/[0.06] px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Loader2 className="h-3.5 w-3.5 text-indigo-400 animate-spin" />
|
||||
<span className="text-xs text-white/35">Analysing your portfolio…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 mt-2">
|
||||
{isExhausted && (
|
||||
<div className="mb-2 rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-2.5 text-center text-xs text-red-400">
|
||||
Monthly limit reached. <Link href="/settings/billing" className="font-semibold underline underline-offset-2">Upgrade for more calls →</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className={`relative flex items-end gap-2 rounded-2xl border bg-[#16161f] p-3 transition-colors ${
|
||||
isExhausted ? "border-red-500/20 opacity-60" : "border-white/[0.08] focus-within:border-indigo-500/40"
|
||||
}`}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
placeholder={isExhausted ? "Monthly limit reached" : "Ask anything about your portfolio…"}
|
||||
rows={1}
|
||||
disabled={loading || isExhausted}
|
||||
className="flex-1 resize-none bg-transparent text-sm text-white placeholder:text-white/25 focus:outline-none min-h-[24px] max-h-32 leading-6 disabled:cursor-not-allowed"
|
||||
onInput={(e) => {
|
||||
const el = e.currentTarget
|
||||
el.style.height = "auto"
|
||||
el.style.height = `${Math.min(el.scrollHeight, 128)}px`
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => send(input)}
|
||||
disabled={!input.trim() || loading || isExhausted}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white transition-all hover:bg-indigo-500 hover:shadow-md hover:shadow-indigo-500/30 disabled:opacity-35 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-center text-[10px] text-white/20">
|
||||
Enter to send · Shift+Enter for new line{!isLocked && ` · ${limit - currentUsed} calls left this month`}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from "@/components/shared/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="h-3.5 w-56" />
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 space-y-4">
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
<Skeleton className="h-12 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { AiChat } from "./ai-chat"
|
||||
import { redirect } from "next/navigation"
|
||||
import { and, eq, gte, sql } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { profiles, usage_events } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { PLAN_LIMITS } from "@/lib/stripe/plans"
|
||||
import type { Plan } from "@/types"
|
||||
|
||||
export const metadata = { title: "AI Assistant — Property Management Network" }
|
||||
|
||||
export default async function AiPage() {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const profile = await db.query.profiles.findFirst({
|
||||
where: eq(profiles.id, user.id),
|
||||
columns: { plan: true },
|
||||
})
|
||||
|
||||
const plan = (profile?.plan ?? "starter") as Plan
|
||||
const limit = PLAN_LIMITS[plan].maxAiCalls
|
||||
|
||||
// Count usage this month
|
||||
const monthStart = new Date()
|
||||
monthStart.setDate(1)
|
||||
monthStart.setHours(0, 0, 0, 0)
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(usage_events)
|
||||
.where(
|
||||
and(
|
||||
eq(usage_events.user_id, user.id),
|
||||
gte(usage_events.created_at, monthStart.toISOString())
|
||||
)
|
||||
)
|
||||
|
||||
const used = count ?? 0
|
||||
|
||||
return <AiChat plan={plan} limit={limit} used={used} />
|
||||
}
|
||||
Reference in New Issue
Block a user