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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -0,0 +1,121 @@
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user