- Exclude supabase/ from Docker build context (leaked service_role key file) - /api/files: exact per-user namespace match + reject path traversal; storage resolveKey rejects ".."/"." segments (fixes cross-user file read) - Add ownsProperty/Unit/Tenant checks to tenants, maintenance (landlord path), and documents (JSON branch, now field-whitelisted) create handlers - Escape user data in follow-up + payment-link emails (reuse escapeHtml) - Neutralize CSV formula injection in toCsv + export routes - Tighter sign-in rate limit (10/min); env-gated email verification + sender - Per-request nonce CSP; drop script-src 'unsafe-inline' (styles unchanged) - Add input length bounds; validate follow-ups POST body Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
7.4 KiB
TypeScript
199 lines
7.4 KiB
TypeScript
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, escapeHtml } 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;">${escapeHtml(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 })
|
|
}
|