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

+62
View File
@@ -1064,3 +1064,65 @@ model BackupSettings {
retentionCount Int?
updatedAt DateTime @updatedAt
}
// ---------------------------------------------------------------------------
// Kalender-Synchronisation – einseitige Übertragung (ApiCore → M365) von
// Kalendereinträgen in den Kalender einer Microsoft-365-Gruppe via Graph API.
// Zugangsdaten (Tenant/Client-ID/-Secret) sind bewusst NICHT Teil dieses
// Modells - wie bei OIDC_CLIENT_SECRET nur über Env-Variablen (Secret), nie
// über die GUI editierbar. groupId ist keine Zugangsdaten-Information und
// daher hier GUI-konfigurierbar.
// ---------------------------------------------------------------------------
enum CalendarSyncSourceType {
TASK
INSPECTION
TREATMENT
FEEDING
HARVEST
QUEEN_ASSIGNMENT
}
enum CalendarSyncStatus {
PENDING
SYNCED
FAILED
SKIPPED
}
model CalendarSyncSettings {
id String @id @default("singleton")
enabled Boolean @default(false)
groupId String?
syncTask Boolean @default(true)
syncInspection Boolean @default(true)
syncTreatment Boolean @default(true)
syncFeeding Boolean @default(true)
syncHarvest Boolean @default(true)
syncQueenAssignment Boolean @default(true)
updatedAt DateTime @updatedAt
}
// Outbox statt synchronem Push direkt im Request-Pfad (anders als das
// Webhooks-Modul): Graph-API-Aufrufe sind zu langsam/fehleranfällig
// (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/
// Aufgaben-Erstellung darauf warten zu lassen. Ein periodischer Worker
// verarbeitet PENDING-Zeilen und wiederholt fehlgeschlagene Versuche.
// @@unique auf [sourceType, sourceId] macht enqueue() ein Upsert: ein
// erneutes Enqueue (z. B. nach einer Aufgaben-Änderung) aktualisiert das
// bestehende Graph-Event über die gespeicherte graphEventId, statt ein
// Duplikat anzulegen.
model CalendarSyncOutboxEntry {
id String @id @default(cuid())
sourceType CalendarSyncSourceType
sourceId String
status CalendarSyncStatus @default(PENDING)
graphEventId String?
attempts Int @default(0)
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sourceType, sourceId])
@@index([status])
}