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/.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", }, }) }