From 167e9a40c336ee7cafbcd0a640bd33576095ced8 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Tue, 19 May 2026 16:26:49 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20Erg=C3=A4nze=20Kalender-Abo=20f?= =?UTF-8?q?=C3=BCr=20Mitarbeiterprofile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0041_profile_calendar_subscription.sql | 2 + backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/auth_profiles.ts | 1 + backend/src/routes/profiles.ts | 44 +++- .../publiclinks-non-authenticated.ts | 26 ++- backend/src/utils/calendarSubscription.ts | 193 ++++++++++++++++++ frontend/pages/staff/profiles/[id].vue | 126 ++++++++++++ 7 files changed, 392 insertions(+), 7 deletions(-) create mode 100644 backend/db/migrations/0041_profile_calendar_subscription.sql create mode 100644 backend/src/utils/calendarSubscription.ts diff --git a/backend/db/migrations/0041_profile_calendar_subscription.sql b/backend/db/migrations/0041_profile_calendar_subscription.sql new file mode 100644 index 0000000..15dc258 --- /dev/null +++ b/backend/db/migrations/0041_profile_calendar_subscription.sql @@ -0,0 +1,2 @@ +ALTER TABLE "auth_profiles" +ADD COLUMN "calendar_subscription_token" text; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index bc82b47..99f107a 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -288,6 +288,13 @@ "when": 1779141600000, "tag": "0040_filetag_system_types", "breakpoints": true + }, + { + "idx": 41, + "version": "7", + "when": 1780149600000, + "tag": "0041_profile_calendar_subscription", + "breakpoints": true } ] } diff --git a/backend/db/schema/auth_profiles.ts b/backend/db/schema/auth_profiles.ts index 572fd2f..a9b6627 100644 --- a/backend/db/schema/auth_profiles.ts +++ b/backend/db/schema/auth_profiles.ts @@ -63,6 +63,7 @@ export const authProfiles = pgTable("auth_profiles", { email: text("email"), token_id: text("token_id"), + calendar_subscription_token: text("calendar_subscription_token"), weekly_working_days: doublePrecision("weekly_working_days"), diff --git a/backend/src/routes/profiles.ts b/backend/src/routes/profiles.ts index 0aa50c9..09c1c66 100644 --- a/backend/src/routes/profiles.ts +++ b/backend/src/routes/profiles.ts @@ -14,6 +14,10 @@ import { resolveTenantTeamIds, syncProfileTeams, } from "../utils/profileTeams"; +import { + enrichProfileWithCalendarSubscription, + generateProfileCalendarSubscriptionToken, +} from "../utils/calendarSubscription"; export default async function authProfilesRoutes(server: FastifyInstance) { @@ -38,7 +42,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) { return reply.code(404).send({ error: "User not found or not in tenant" }); } - return profile; + return enrichProfileWithCalendarSubscription(profile); } catch (error) { console.error("GET /profiles/:id ERROR:", error); @@ -50,10 +54,11 @@ export default async function authProfilesRoutes(server: FastifyInstance) { const cleaned: any = { ...body } // ❌ Systemfelder entfernen - const forbidden = [ - "id", "user_id", "tenant_id", "created_at", "updated_at", - "updatedAt", "updatedBy", "old_profile_id", "full_name", - "branch" + const forbidden = [ + "id", "user_id", "tenant_id", "created_at", "updated_at", + "updatedAt", "updatedBy", "old_profile_id", "full_name", + "branch", "calendar_subscription_token", + "calendar_subscription_path", "calendar_subscription_url" ] forbidden.forEach(f => delete cleaned[f]) @@ -146,7 +151,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) { const [profile] = profileWithBranches ? await enrichProfilesWithTeams(server, [profileWithBranches]) : [null] - return profile || updated[0] + return enrichProfileWithCalendarSubscription(profile || updated[0]) } catch (err) { console.error("PUT /profiles/:id ERROR:", err) @@ -159,4 +164,31 @@ export default async function authProfilesRoutes(server: FastifyInstance) { return reply.code(500).send({ error: "Internal Server Error" }) } }) + + server.post("/profiles/:id/calendar-subscription-token", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + + if (!tenantId) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const { id } = req.params as { id: string } + const updatedProfile = await generateProfileCalendarSubscriptionToken(server, id, tenantId) + + if (!updatedProfile) { + return reply.code(404).send({ error: "User not found or not in tenant" }) + } + + const profileWithBranches = await loadProfileWithBranches(server, id, tenantId) + const [profile] = profileWithBranches + ? await enrichProfilesWithTeams(server, [profileWithBranches]) + : [updatedProfile] + + return enrichProfileWithCalendarSubscription(profile) + } catch (err) { + console.error("POST /profiles/:id/calendar-subscription-token ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) } diff --git a/backend/src/routes/publiclinks/publiclinks-non-authenticated.ts b/backend/src/routes/publiclinks/publiclinks-non-authenticated.ts index af386a8..1e5d517 100644 --- a/backend/src/routes/publiclinks/publiclinks-non-authenticated.ts +++ b/backend/src/routes/publiclinks/publiclinks-non-authenticated.ts @@ -1,8 +1,32 @@ import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify'; import { publicLinkService } from '../../modules/publiclinks.service'; import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs +import { buildProfileCalendarSubscriptionFeed, loadProfileByCalendarSubscriptionToken } from '../../utils/calendarSubscription'; export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) { + server.get("/api/public/calendar/subscriptions/:token.ics", async (req, reply) => { + const { token } = req.params as { token: string } + + try { + const profile = await loadProfileByCalendarSubscriptionToken(server, token) + + if (!profile || !profile.active) { + return reply.code(404).send({ error: "Kalender-Abo nicht gefunden" }) + } + + const icsFeed = await buildProfileCalendarSubscriptionFeed(server, profile) + + reply.header("Content-Type", "text/calendar; charset=utf-8") + reply.header("Content-Disposition", `inline; filename="fedeo-${profile.id}.ics"`) + reply.header("Cache-Control", "private, max-age=300") + + return reply.send(icsFeed) + } catch (error: any) { + server.log.error(error) + return reply.code(500).send({ error: "Interner Server Fehler" }) + } + }) + server.get("/workflows/context/:token", async (req, reply) => { const { token } = req.params as { token: string }; const pin = req.headers['x-public-pin'] as string | undefined; @@ -49,4 +73,4 @@ export default async function publiclinksNonAuthenticatedRoutes(server: FastifyI return reply.code(500).send({ error: "Fehler beim Speichern", details: error.message }); } }); -} \ No newline at end of file +} diff --git a/backend/src/utils/calendarSubscription.ts b/backend/src/utils/calendarSubscription.ts new file mode 100644 index 0000000..d388f56 --- /dev/null +++ b/backend/src/utils/calendarSubscription.ts @@ -0,0 +1,193 @@ +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` +} diff --git a/frontend/pages/staff/profiles/[id].vue b/frontend/pages/staff/profiles/[id].vue index 63d5b29..17e45a2 100644 --- a/frontend/pages/staff/profiles/[id].vue +++ b/frontend/pages/staff/profiles/[id].vue @@ -5,6 +5,7 @@ const toast = useToast() const auth = useAuthStore() const admin = useAdmin() const { $api } = useNuxtApp() +const runtimeConfig = useRuntimeConfig() const id = route.params.id as string const profile = ref(null) @@ -15,6 +16,7 @@ const saving = ref(false) const creatingLinkedUser = ref(false) const createLinkedUserModalOpen = ref(false) const createdLinkedUserPassword = ref("") +const generatingCalendarSubscription = ref(false) const createLinkedUserForm = reactive({ email: "", }) @@ -26,6 +28,30 @@ const selectMenuUi = { const canCreateLinkedUser = computed(() => Boolean(auth.user?.is_admin && profile.value && !profile.value.user_id)) const linkedUserStatusLabel = computed(() => profile.value?.user_id ? "Benutzer verknüpft" : "Kein Benutzer verknüpft") const linkedUserStatusColor = computed(() => profile.value?.user_id ? "green" : "orange") +const calendarSubscriptionHttpUrl = computed(() => { + const token = profile.value?.calendar_subscription_token + if (!token) return "" + + const path = profile.value?.calendar_subscription_path || `/api/public/calendar/subscriptions/${token}.ics` + const apiBase = String(runtimeConfig.public.apiBase || "") + + if (/^https?:\/\//i.test(apiBase)) { + const apiUrl = new URL(apiBase) + return new URL(path, `${apiUrl.protocol}//${apiUrl.host}`).toString() + } + + if (process.client) { + return `${window.location.origin}${path}` + } + + return path +}) + +const calendarSubscriptionWebcalUrl = computed(() => + calendarSubscriptionHttpUrl.value + ? calendarSubscriptionHttpUrl.value.replace(/^https?/i, "webcal") + : "" +) async function fetchBranches() { try { @@ -195,6 +221,55 @@ async function createLinkedUser() { } } +async function generateCalendarSubscription() { + if (!profile.value || generatingCalendarSubscription.value) return + + generatingCalendarSubscription.value = true + + try { + profile.value = await $api(`/api/profiles/${id}/calendar-subscription-token`, { + method: "POST" + }) + + ensureWorkingHoursStructure() + ensureBranchStructure() + ensureTeamStructure() + + toast.add({ + title: "Kalender-Abo erstellt", + description: "Der abonnierbare Kalender-Link wurde generiert.", + color: "green" + }) + } catch (err: any) { + console.error("[generateCalendarSubscription]", err) + toast.add({ + title: "Kalender-Abo konnte nicht erstellt werden", + description: err?.data?.error || err?.message || "Unbekannter Fehler", + color: "red" + }) + } finally { + generatingCalendarSubscription.value = false + } +} + +async function copyCalendarSubscriptionUrl(value: string, successTitle: string) { + if (!value) return + + try { + await navigator.clipboard.writeText(value) + toast.add({ + title: successTitle, + color: "green" + }) + } catch (err) { + console.error("[copyCalendarSubscriptionUrl]", err) + toast.add({ + title: "Link konnte nicht kopiert werden", + color: "red" + }) + } +} + const weekdays = [ { key: '1', label: 'Montag' }, { key: '2', label: 'Dienstag' }, @@ -471,6 +546,57 @@ onMounted(async () => { + + + +
+

+ Hier kann ein abonnierbarer Kalender-Link für Handy-Kalender erzeugt werden. Das Abo nutzt einen persönlichen Backend-Link und kann in vielen Kalender-Apps direkt als `webcal` oder `ics` eingebunden werden. +

+ +
+ + {{ profile.calendar_subscription_token ? 'Link neu generieren' : 'Link generieren' }} + + + + ICS kopieren + + + + Webcal kopieren + +
+ + + + + + + + + + +
+
+