KI-AGENT: Ergänze Kalender-Abo für Mitarbeiterprofile

This commit is contained in:
2026-05-19 16:26:49 +02:00
parent f9d3f10eae
commit 167e9a40c3
7 changed files with 392 additions and 7 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "auth_profiles"
ADD COLUMN "calendar_subscription_token" text;

View File

@@ -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
}
]
}

View File

@@ -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"),

View File

@@ -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" })
}
})
}

View File

@@ -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 });
}
});
}
}

View File

@@ -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<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`
}