feat: sync calendar entries to a Microsoft 365 group calendar
Backend Image / build-and-push (push) Successful in 8m34s
Frontend Image / build-and-push (push) Successful in 8m41s

Adds a one-way push (ApiCore -> M365) of Aufgaben/Durchsichten/
Behandlungen/Fütterungen/Ernten/Königinnen-Zuordnungen into the calendar
of a Microsoft 365 group, via the Graph API.

- CalendarSyncModule (app-only client-credentials Graph auth, separate
  from the interactive SSO app registration; new env vars
  M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET).
- Outbox + minute-interval cron worker (CalendarSyncOutboxEntry) instead
  of a synchronous push, since Graph calls are too slow/flaky to block a
  domain create request on; failed syncs retry up to 5 times.
- Per-source-type toggle plus a group-ID field, configurable in
  Verwaltung > Automatisierung > Kalender-Sync (Verwaltung > Backup, an
  env var still wins on those fields where one applies to related config).
- Hooked into Tasks/Inspections/Treatments/Feedings/Harvests/Queens at
  their existing create (and Task update/complete) points; entries render
  as all-day Graph events to avoid timezone ambiguity.
- Documents the design decision in ARCHITECTURE.md (Annahme 9), since
  this is a net-new capability the lastenheft doesn't scope.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 09:23:35 +02:00
1 parent 28ae0a2723
commit e7b157ec35
35 files changed
+1760 -7

No files matched your search

@@ -0,0 +1,208 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
getCalendarSyncSettings,
getCalendarSyncStatus,
runCalendarSyncNow,
updateCalendarSyncSettings,
} from "@/lib/calendar-sync/api";
import { CalendarSyncSettings } from "@/lib/calendar-sync/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type SyncTypeKey =
| "syncTask"
| "syncInspection"
| "syncTreatment"
| "syncFeeding"
| "syncHarvest"
| "syncQueenAssignment";
const SYNC_TYPE_FIELDS: { key: SyncTypeKey; label: string }[] = [
{ key: "syncTask", label: "Aufgaben" },
{ key: "syncInspection", label: "Durchsichten" },
{ key: "syncTreatment", label: "Behandlungen" },
{ key: "syncFeeding", label: "Fütterungen" },
{ key: "syncHarvest", label: "Ernten" },
{ key: "syncQueenAssignment", label: "Königinnen-Zuordnungen" },
];
// Mounted only once `settings` has arrived, so its useState initial values
// already reflect the fetched data - matches WebsiteSettingsForm's pattern
// in social-media-tab.tsx.
function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
const queryClient = useQueryClient();
const [enabled, setEnabled] = useState(settings.enabled);
const [groupId, setGroupId] = useState(settings.groupId ?? "");
const [typeFlags, setTypeFlags] = useState<Record<SyncTypeKey, boolean>>({
syncTask: settings.syncTask,
syncInspection: settings.syncInspection,
syncTreatment: settings.syncTreatment,
syncFeeding: settings.syncFeeding,
syncHarvest: settings.syncHarvest,
syncQueenAssignment: settings.syncQueenAssignment,
});
const mutation = useMutation({
mutationFn: updateCalendarSyncSettings,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["calendar-sync-settings"] });
void queryClient.invalidateQueries({ queryKey: ["calendar-sync-status"] });
toast.success("Einstellungen gespeichert");
},
onError: () => toast.error("Einstellungen konnten nicht gespeichert werden"),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate({ enabled, groupId, ...typeFlags });
}
return (
<Card className="max-w-lg">
<CardHeader>
<CardTitle>M365-Kalender-Synchronisation</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<p className="text-xs text-muted-foreground">
Überträgt Kalendereinträge einseitig (ApiCore → M365) in den Kalender einer
Microsoft-365-Gruppe. Benötigt eine eigene App-Registrierung in Azure/Entra
(Umgebungsvariablen, siehe .env.example) mit Application-Berechtigung
„Calendars.ReadWrite“ und Admin-Consent.
</p>
<div className="flex items-center gap-2">
<Checkbox
id="sync-enabled"
checked={enabled}
onCheckedChange={(checked) => setEnabled(checked === true)}
/>
<Label htmlFor="sync-enabled">Synchronisation aktiv</Label>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sync-group-id">Objekt-ID der M365-Gruppe</Label>
<Input
id="sync-group-id"
value={groupId}
onChange={(event) => setGroupId(event.target.value)}
placeholder="z. B. 3fa85f64-5717-4562-b3fc-2c963f66afa6"
/>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Welche Einträge übertragen werden</p>
{SYNC_TYPE_FIELDS.map((field) => (
<div key={field.key} className="flex items-center gap-2">
<Checkbox
id={`sync-${field.key}`}
checked={typeFlags[field.key]}
onCheckedChange={(checked) =>
setTypeFlags((prev) => ({ ...prev, [field.key]: checked === true }))
}
/>
<Label htmlFor={`sync-${field.key}`}>{field.label}</Label>
</div>
))}
</div>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
</Button>
</form>
</CardContent>
</Card>
);
}
function StatusSection() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ["calendar-sync-status"],
queryFn: getCalendarSyncStatus,
});
const runNowMutation = useMutation({
mutationFn: runCalendarSyncNow,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["calendar-sync-status"] });
toast.success("Synchronisation ausgeführt");
},
onError: () => toast.error("Synchronisation fehlgeschlagen"),
});
const status = statusQuery.data;
const canRunNow = Boolean(status?.configured && status.enabled && status.groupId);
return (
<Card className="max-w-lg">
<CardHeader>
<CardTitle>Status</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
{statusQuery.isLoading ? (
<p className="text-muted-foreground">Lädt…</p>
) : !status?.configured ? (
<p className="text-muted-foreground">
Nicht konfiguriert - M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET fehlen als
Umgebungsvariable.
</p>
) : !canRunNow ? (
<p className="text-muted-foreground">
Zugangsdaten vorhanden, aber Synchronisation ist deaktiviert oder es ist keine
Gruppen-ID gesetzt.
</p>
) : (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
<dt className="text-muted-foreground">Status</dt>
<dd>
<Badge variant="default">Aktiv</Badge>
</dd>
<dt className="text-muted-foreground">Ausstehend</dt>
<dd>{status.pendingCount}</dd>
<dt className="text-muted-foreground">Fehlgeschlagen</dt>
<dd>{status.failedCount}</dd>
<dt className="text-muted-foreground">Übertragen</dt>
<dd>{status.syncedCount}</dd>
</dl>
)}
<Button
type="button"
variant="outline"
disabled={!canRunNow || runNowMutation.isPending}
onClick={() => runNowMutation.mutate()}
>
{runNowMutation.isPending ? "Wird synchronisiert…" : "Jetzt synchronisieren"}
</Button>
</CardContent>
</Card>
);
}
export function CalendarSyncTab() {
const {
data: settings,
isLoading,
isError,
} = useQuery({
queryKey: ["calendar-sync-settings"],
queryFn: getCalendarSyncSettings,
});
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Einstellungen konnten nicht geladen werden.
</p>
) : null}
{settings ? <SettingsForm settings={settings} /> : null}
<StatusSection />
</div>
);
}
@@ -10,8 +10,15 @@ import { TaskTemplatesTab } from "./task-templates-tab";
import { LabelTemplatesTab } from "./label-templates-tab";
import { SocialMediaTab } from "./social-media-tab";
import { AutomationRulesTab } from "./automation-rules-tab";
import { CalendarSyncTab } from "./calendar-sync-tab";
type Tab = "webhooks" | "task-templates" | "label-templates" | "social-media" | "rules";
type Tab =
| "webhooks"
| "task-templates"
| "label-templates"
| "social-media"
| "rules"
| "calendar-sync";
const TABS: { value: Tab; label: string }[] = [
{ value: "webhooks", label: "Webhooks" },
@@ -19,6 +26,7 @@ const TABS: { value: Tab; label: string }[] = [
{ value: "label-templates", label: "Etikettenvorlagen" },
{ value: "social-media", label: "Social Media" },
{ value: "rules", label: "Regeln" },
{ value: "calendar-sync", label: "Kalender-Sync" },
];
function isTab(value: string | null): value is Tab {
@@ -39,7 +47,7 @@ function AutomationPageContent() {
<PageHeader
icon={Zap}
title="Automatisierung"
description="Webhooks, wiederkehrende Aufgaben, Etikettenvorlagen, Social Media und Regeln"
description="Webhooks, wiederkehrende Aufgaben, Etikettenvorlagen, Social Media, Regeln und Kalender-Sync"
gradient="var(--gradient-sky)"
/>
@@ -50,6 +58,7 @@ function AutomationPageContent() {
{tab === "label-templates" ? <LabelTemplatesTab /> : null}
{tab === "social-media" ? <SocialMediaTab /> : null}
{tab === "rules" ? <AutomationRulesTab /> : null}
{tab === "calendar-sync" ? <CalendarSyncTab /> : null}
</main>
);
}
@@ -16,6 +16,8 @@ const SETTINGS_AREA_PERMISSIONS = [
"import:execute",
"backup:export",
"backup:import",
"backup:configure",
"calendar:configure",
];
export default function SettingsLayout({ children }: { children: ReactNode }) {
@@ -104,6 +104,7 @@ const ADMIN_GROUP: NavGroup = {
"task-templates:read",
"label-templates:read",
"site-settings:read",
"calendar:configure",
],
},
{