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,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;
}