194 lines
6.2 KiB
TypeScript
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`
|
|
}
|