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,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");
|
||||
@@ -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])
|
||||
}
|
||||
Reference in new issue
Block a user