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>
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
import { resend, FROM_EMAIL, APP_NAME } from "./client"
|
|
|
|
function escapeHtml(value: unknown): string {
|
|
return String(value ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
}
|
|
|
|
interface SendEmailOptions {
|
|
to: string
|
|
subject: string
|
|
html: string
|
|
from?: string
|
|
}
|
|
|
|
export async function sendEmail({ to, subject, html, from }: SendEmailOptions) {
|
|
const { data, error } = await resend.emails.send({
|
|
from: from ?? `${APP_NAME} <${FROM_EMAIL}>`,
|
|
to,
|
|
subject,
|
|
html,
|
|
})
|
|
|
|
if (error) {
|
|
console.error("Email send failed:", error)
|
|
return { success: false, error }
|
|
}
|
|
|
|
return { success: true, id: data?.id }
|
|
}
|
|
|
|
// ── Email templates ──────────────────────────────────────────────
|
|
|
|
export function rentDueReminderHtml({
|
|
tenantName,
|
|
propertyName,
|
|
unitNumber,
|
|
amount,
|
|
dueDate,
|
|
paymentLink,
|
|
}: {
|
|
tenantName: string
|
|
propertyName: string
|
|
unitNumber: string
|
|
amount: string
|
|
dueDate: string
|
|
paymentLink?: string
|
|
}) {
|
|
return `
|
|
<!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;">
|
|
<h1 style="font-size: 20px; margin: 0 0 8px; color: #fff;">Rent Due Reminder</h1>
|
|
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
|
|
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
|
|
Your rent payment of <strong style="color:#fff">${escapeHtml(amount)}</strong> for
|
|
<strong style="color:#fff">${escapeHtml(propertyName)} — Unit ${escapeHtml(unitNumber)}</strong>
|
|
is due on <strong style="color:#fff">${escapeHtml(dueDate)}</strong>.
|
|
</p>
|
|
${paymentLink ? `
|
|
<a href="${escapeHtml(paymentLink)}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">
|
|
Pay Rent Now
|
|
</a>
|
|
` : ""}
|
|
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">
|
|
Property Management Network
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
export function rentOverdueHtml({
|
|
tenantName,
|
|
propertyName,
|
|
unitNumber,
|
|
amount,
|
|
dueDate,
|
|
}: {
|
|
tenantName: string
|
|
propertyName: string
|
|
unitNumber: string
|
|
amount: string
|
|
dueDate: string
|
|
}) {
|
|
return `
|
|
<!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(239,68,68,0.3); border-radius: 12px; padding: 32px;">
|
|
<h1 style="font-size: 20px; margin: 0 0 8px; color: #ef4444;">Rent Overdue</h1>
|
|
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
|
|
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
|
|
Your rent payment of <strong style="color:#fff">${escapeHtml(amount)}</strong> for
|
|
<strong style="color:#fff">${escapeHtml(propertyName)} — Unit ${escapeHtml(unitNumber)}</strong>
|
|
was due on <strong style="color:#ef4444">${escapeHtml(dueDate)}</strong> and is now overdue.
|
|
Please make payment as soon as possible.
|
|
</p>
|
|
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">
|
|
Property Management Network
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
export function leaseExpiryHtml({
|
|
tenantName,
|
|
propertyName,
|
|
unitNumber,
|
|
leaseEnd,
|
|
daysLeft,
|
|
}: {
|
|
tenantName: string
|
|
propertyName: string
|
|
unitNumber: string
|
|
leaseEnd: string
|
|
daysLeft: number
|
|
}) {
|
|
return `
|
|
<!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(245,158,11,0.3); border-radius: 12px; padding: 32px;">
|
|
<h1 style="font-size: 20px; margin: 0 0 8px; color: #f59e0b;">Lease Expiring Soon</h1>
|
|
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
|
|
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">
|
|
Your lease for <strong style="color:#fff">${escapeHtml(propertyName)} — Unit ${escapeHtml(unitNumber)}</strong>
|
|
expires on <strong style="color:#f59e0b">${escapeHtml(leaseEnd)}</strong>
|
|
(${escapeHtml(daysLeft)} days from now). Please contact your landlord to discuss renewal.
|
|
</p>
|
|
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">
|
|
Property Management Network
|
|
</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
export function maintenanceUpdateHtml({
|
|
tenantName,
|
|
title,
|
|
status,
|
|
resolutionNotes,
|
|
}: {
|
|
tenantName: string
|
|
title: string
|
|
status: string
|
|
resolutionNotes?: string
|
|
}) {
|
|
return `
|
|
<!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;">
|
|
<h1 style="font-size: 20px; margin: 0 0 8px;">Maintenance Update</h1>
|
|
<p style="color: rgba(255,255,255,0.6); margin: 0 0 24px;">Hi ${escapeHtml(tenantName)},</p>
|
|
<p style="color: rgba(255,255,255,0.6); margin: 0 0 12px;">
|
|
Your maintenance request "<strong style="color:#fff">${escapeHtml(title)}</strong>"
|
|
has been updated to: <strong style="color:#6366f1; text-transform: capitalize;">${escapeHtml(status).replace("_", " ")}</strong>
|
|
</p>
|
|
${resolutionNotes ? `<p style="color: rgba(255,255,255,0.5); margin: 0 0 24px; font-size: 14px;">${escapeHtml(resolutionNotes)}</p>` : ""}
|
|
<p style="color: rgba(255,255,255,0.4); font-size: 12px; margin: 24px 0 0;">Property Management Network</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
}
|