Files
property-management-network/app/api/calendar/[token]/route.ts
T
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00

122 lines
4.6 KiB
TypeScript

import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles, rent_payments, leases, inspections } from "@/lib/db/schema"
// Public, token-authenticated iCal (ICS) subscription feed. A landlord subscribes
// to /api/calendar/<calendar_token>.ics in Google/Apple/Outlook and their rent
// due dates, lease expiries, and inspections appear (read-only, auto-refreshing).
export const dynamic = "force-dynamic"
const PRODID = "-//Property Management Network//Calendar//EN"
function icsDate(d: string): string {
return d.slice(0, 10).replace(/-/g, "")
}
function icsDatePlusOne(d: string): string {
const dt = new Date(d.slice(0, 10) + "T00:00:00Z")
dt.setUTCDate(dt.getUTCDate() + 1)
return dt.toISOString().slice(0, 10).replace(/-/g, "")
}
function esc(s: unknown): string {
return String(s ?? "").replace(/[\\;,]/g, (m) => "\\" + m).replace(/\r?\n/g, "\\n")
}
// Fold lines to 75 octets per RFC 5545.
function fold(line: string): string {
if (line.length <= 75) return line
const parts: string[] = []
let rest = line
parts.push(rest.slice(0, 75))
rest = rest.slice(75)
while (rest.length > 74) {
parts.push(" " + rest.slice(0, 74))
rest = rest.slice(74)
}
if (rest.length) parts.push(" " + rest)
return parts.join("\r\n")
}
export async function GET(_req: Request, { params }: { params: Promise<{ token: string }> }) {
const { token: raw } = await params
const token = raw.replace(/\.ics$/i, "")
if (!token) return new Response("Not found", { status: 404 })
const profile = await db.query.profiles.findFirst({
where: eq(profiles.calendar_token, token),
columns: { id: true },
})
if (!profile) return new Response("Not found", { status: 404 })
const ownerId = profile.id
const [payments, leaseList, inspList] = await Promise.all([
db.query.rent_payments.findMany({
where: eq(rent_payments.user_id, ownerId),
columns: { id: true, due_date: true, amount: true, status: true },
with: { tenant: { columns: { first_name: true, last_name: true } }, property: { columns: { name: true } }, unit: { columns: { unit_number: true } } },
}),
db.query.leases.findMany({
where: and(eq(leases.user_id, ownerId), eq(leases.status, "active")),
columns: { id: true, lease_end: true },
with: { tenant: { columns: { first_name: true, last_name: true } }, property: { columns: { name: true } } },
}),
db.query.inspections.findMany({
where: eq(inspections.user_id, ownerId),
columns: { id: true, date: true, type: true, status: true },
with: { property: { columns: { name: true } }, unit: { columns: { unit_number: true } } },
}),
])
const stamp = new Date().toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"
const out: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
`PRODID:${PRODID}`,
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:Property Management Network",
"X-WR-TIMEZONE:UTC",
"REFRESH-INTERVAL;VALUE=DURATION:PT6H",
"X-PUBLISHED-TTL:PT6H",
]
const addEvent = (uid: string, date: string, summary: string, description: string) => {
out.push(
"BEGIN:VEVENT",
fold(`UID:${uid}@propertymanagement.network`),
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${icsDate(date)}`,
`DTEND;VALUE=DATE:${icsDatePlusOne(date)}`,
fold(`SUMMARY:${esc(summary)}`),
fold(`DESCRIPTION:${esc(description)}`),
"TRANSP:TRANSPARENT",
"END:VEVENT"
)
}
for (const p of payments) {
if (!p.due_date) continue
const who = `${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}`.trim() || "Tenant"
const amt = `$${Number(p.amount).toLocaleString("en-US")}`
addEvent(`rent-${p.id}`, p.due_date, `Rent due — ${who} (${amt})`, `${p.status.toUpperCase()} · ${p.property?.name ?? ""}${p.unit ? ` Unit ${p.unit.unit_number}` : ""}`)
}
for (const l of leaseList) {
if (!l.lease_end) continue
const who = `${l.tenant?.first_name ?? ""} ${l.tenant?.last_name ?? ""}`.trim() || "Tenant"
addEvent(`lease-${l.id}`, l.lease_end, `Lease ends — ${who}`, `${l.property?.name ?? ""}`)
}
for (const ins of inspList) {
if (!ins.date) continue
const type = ins.type.replace("_", "-")
addEvent(`insp-${ins.id}`, ins.date, `${type} inspection`, `${ins.property?.name ?? ""}${ins.unit ? ` Unit ${ins.unit.unit_number}` : ""} · ${ins.status}`)
}
out.push("END:VCALENDAR")
return new Response(out.join("\r\n") + "\r\n", {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="property-management-network.ics"',
"Cache-Control": "public, max-age=3600",
},
})
}