Files
FEDEO/backend/src/utils/calendarSubscription.ts

194 lines
6.2 KiB
TypeScript

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<string, any> | 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`
}