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>
122 lines
4.6 KiB
TypeScript
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",
|
|
},
|
|
})
|
|
}
|