feat: sync calendar entries to a Microsoft 365 group calendar
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:
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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import {
|
||||
CalendarSyncSettings,
|
||||
CalendarSyncStatus,
|
||||
UpdateCalendarSyncSettingsInput,
|
||||
} from "./types";
|
||||
|
||||
export function getCalendarSyncSettings(): Promise<CalendarSyncSettings> {
|
||||
return apiFetch<CalendarSyncSettings>("/calendar/sync/settings");
|
||||
}
|
||||
|
||||
export function updateCalendarSyncSettings(
|
||||
input: UpdateCalendarSyncSettingsInput,
|
||||
): Promise<CalendarSyncSettings> {
|
||||
return apiFetch<CalendarSyncSettings>("/calendar/sync/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function getCalendarSyncStatus(): Promise<CalendarSyncStatus> {
|
||||
return apiFetch<CalendarSyncStatus>("/calendar/sync/status");
|
||||
}
|
||||
|
||||
export function runCalendarSyncNow(): Promise<CalendarSyncStatus> {
|
||||
return apiFetch<CalendarSyncStatus>("/calendar/sync/run-now", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface CalendarSyncSettings {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
groupId: string | null;
|
||||
syncTask: boolean;
|
||||
syncInspection: boolean;
|
||||
syncTreatment: boolean;
|
||||
syncFeeding: boolean;
|
||||
syncHarvest: boolean;
|
||||
syncQueenAssignment: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateCalendarSyncSettingsInput {
|
||||
enabled?: boolean;
|
||||
groupId?: string;
|
||||
syncTask?: boolean;
|
||||
syncInspection?: boolean;
|
||||
syncTreatment?: boolean;
|
||||
syncFeeding?: boolean;
|
||||
syncHarvest?: boolean;
|
||||
syncQueenAssignment?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarSyncStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
groupId: string | null;
|
||||
pendingCount: number;
|
||||
failedCount: number;
|
||||
syncedCount: number;
|
||||
}
|
||||
Reference in new issue
Block a user