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,42 @@
-- CreateEnum
CREATE TYPE "CalendarSyncSourceType" AS ENUM ('TASK', 'INSPECTION', 'TREATMENT', 'FEEDING', 'HARVEST', 'QUEEN_ASSIGNMENT');
-- CreateEnum
CREATE TYPE "CalendarSyncStatus" AS ENUM ('PENDING', 'SYNCED', 'FAILED', 'SKIPPED');
-- CreateTable
CREATE TABLE "CalendarSyncSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"enabled" BOOLEAN NOT NULL DEFAULT false,
"groupId" TEXT,
"syncTask" BOOLEAN NOT NULL DEFAULT true,
"syncInspection" BOOLEAN NOT NULL DEFAULT true,
"syncTreatment" BOOLEAN NOT NULL DEFAULT true,
"syncFeeding" BOOLEAN NOT NULL DEFAULT true,
"syncHarvest" BOOLEAN NOT NULL DEFAULT true,
"syncQueenAssignment" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalendarSyncSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CalendarSyncOutboxEntry" (
"id" TEXT NOT NULL,
"sourceType" "CalendarSyncSourceType" NOT NULL,
"sourceId" TEXT NOT NULL,
"status" "CalendarSyncStatus" NOT NULL DEFAULT 'PENDING',
"graphEventId" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalendarSyncOutboxEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CalendarSyncOutboxEntry_status_idx" ON "CalendarSyncOutboxEntry"("status");
-- CreateIndex
CREATE UNIQUE INDEX "CalendarSyncOutboxEntry_sourceType_sourceId_key" ON "CalendarSyncOutboxEntry"("sourceType", "sourceId");
+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])
}