import crypto from "crypto" import { and, asc, eq, sql } from "drizzle-orm" import { FastifyInstance } from "fastify" import { authProfiles, events } from "../../db/schema" import { secrets } from "./secrets" const CALENDAR_FEED_PREFIX = "/api/public/calendar/subscriptions" function escapeIcsText(value: string) { return value .replace(/\\/g, "\\\\") .replace(/\r?\n/g, "\\n") .replace(/;/g, "\\;") .replace(/,/g, "\\,") } function foldIcsLine(line: string) { const maxLength = 73 if (line.length <= maxLength) return line const parts: string[] = [] for (let index = 0; index < line.length; index += maxLength) { parts.push(index === 0 ? line.slice(index, index + maxLength) : ` ${line.slice(index, index + maxLength)}`) } return parts.join("\r\n") } function formatUtcDate(value: string | Date | null | undefined) { if (!value) return null const date = new Date(value) if (Number.isNaN(date.getTime())) return null return date .toISOString() .replace(/[-:]/g, "") .replace(/\.\d{3}Z$/, "Z") } function buildRecurrenceRule(repeatInterval?: string | null) { switch (repeatInterval) { case "Täglich": return "FREQ=DAILY" case "Wöchentlich": return "FREQ=WEEKLY" case "2-wöchentlich": return "FREQ=WEEKLY;INTERVAL=2" case "Monatlich": return "FREQ=MONTHLY" case "Jährlich": return "FREQ=YEARLY" default: return null } } function normalizeApiBaseUrl() { const rawBase = secrets.API_BASE_URL?.trim() if (!rawBase) return null return rawBase.replace(/\/+$/, "") } export function buildCalendarSubscriptionPath(token: string) { return `${CALENDAR_FEED_PREFIX}/${token}.ics` } export function buildCalendarSubscriptionUrl(token: string) { const apiBaseUrl = normalizeApiBaseUrl() const subscriptionPath = buildCalendarSubscriptionPath(token) if (!apiBaseUrl) return subscriptionPath if (apiBaseUrl.endsWith("/api")) { return `${apiBaseUrl}${subscriptionPath.replace(/^\/api/, "")}` } return `${apiBaseUrl}${subscriptionPath}` } export function enrichProfileWithCalendarSubscription(profile: Record | null) { if (!profile) return profile return { ...profile, calendar_subscription_path: profile.calendar_subscription_token ? buildCalendarSubscriptionPath(profile.calendar_subscription_token) : null, calendar_subscription_url: profile.calendar_subscription_token ? buildCalendarSubscriptionUrl(profile.calendar_subscription_token) : null, } } export async function generateProfileCalendarSubscriptionToken(server: FastifyInstance, profileId: string, tenantId: number) { const token = crypto.randomBytes(24).toString("hex") const [profile] = await server.db .update(authProfiles) .set({ calendar_subscription_token: token, }) .where( and( eq(authProfiles.id, profileId), eq(authProfiles.tenant_id, tenantId) ) ) .returning() return profile || null } export async function loadProfileByCalendarSubscriptionToken(server: FastifyInstance, token: string) { return server.db.query.authProfiles.findFirst({ where: eq(authProfiles.calendar_subscription_token, token) }) } export async function buildProfileCalendarSubscriptionFeed(server: FastifyInstance, profile: typeof authProfiles.$inferSelect) { const assignedEvents = await server.db .select({ id: events.id, name: events.name, startDate: events.startDate, endDate: events.endDate, repeatInterval: events.repeatInterval, notes: events.notes, link: events.link, state: events.state, color: events.color, createdAt: events.createdAt, updatedAt: events.updatedAt }) .from(events) .where( and( eq(events.tenant, profile.tenant_id), eq(events.archived, false), sql`${events.profiles} ? ${profile.id}` ) ) .orderBy(asc(events.startDate)) const nowStamp = formatUtcDate(new Date()) || "" const calendarLines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//FEDEO//Kalender-Abo//DE", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", foldIcsLine(`X-WR-CALNAME:${escapeIcsText(`FEDEO - ${profile.full_name}`)}`), foldIcsLine(`X-WR-CALDESC:${escapeIcsText(`Kalender-Abo für ${profile.full_name}`)}`), ] assignedEvents.forEach((event) => { const startDate = formatUtcDate(event.startDate) if (!startDate) return const endDate = formatUtcDate(event.endDate) const lastModified = formatUtcDate(event.updatedAt || event.createdAt) || nowStamp const recurrenceRule = buildRecurrenceRule(event.repeatInterval) const descriptionParts = [event.notes, event.link].filter(Boolean) const summary = event.state === "Entwurf" ? `[Entwurf] ${event.name}` : event.name calendarLines.push("BEGIN:VEVENT") calendarLines.push(foldIcsLine(`UID:fedeo-event-${event.id}@fedeo.local`)) calendarLines.push(`DTSTAMP:${nowStamp}`) calendarLines.push(`DTSTART:${startDate}`) if (endDate) { calendarLines.push(`DTEND:${endDate}`) } calendarLines.push(`LAST-MODIFIED:${lastModified}`) calendarLines.push(foldIcsLine(`SUMMARY:${escapeIcsText(summary)}`)) calendarLines.push(`STATUS:${event.state === "Entwurf" ? "TENTATIVE" : "CONFIRMED"}`) if (event.color) { calendarLines.push(foldIcsLine(`COLOR:${escapeIcsText(event.color)}`)) } if (descriptionParts.length) { calendarLines.push(foldIcsLine(`DESCRIPTION:${escapeIcsText(descriptionParts.join("\n\n"))}`)) } if (recurrenceRule) { calendarLines.push(`RRULE:${recurrenceRule}`) } calendarLines.push("END:VEVENT") }) calendarLines.push("END:VCALENDAR") return `${calendarLines.join("\r\n")}\r\n` }