diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e30ec0e..3dc252a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -97,6 +97,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt. 7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. 8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. +9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`, eigene, von der SSO-App-Registrierung getrennte Zugangsdaten `M365_CALENDAR_TENANT_ID`/`_CLIENT_ID`/`_CLIENT_SECRET`, ausschließlich Env-Variablen) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen. ## 7. Kubernetes diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index ac8710f..91a0e88 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -48,6 +48,20 @@ OIDC_CLIENT_ID="change-me-entra-client-id" OIDC_CLIENT_SECRET="change-me-entra-client-secret" OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback" +# M365-Kalender-Synchronisation (Modul Kalender). Eintraege (Aufgaben, +# Durchsichten, Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen) +# werden einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365- +# Gruppe uebertragen. Eigene, separate App-Registrierung in Azure/Entra +# (NICHT dieselbe wie OIDC_CLIENT_ID oben) mit Application-Permission +# "Calendars.ReadWrite" (Admin-Consent noetig), Client-Credentials-Flow. +# Ohne alle drei Werte bleibt die Synchronisation deaktiviert. Zusaetzlich +# muss die Synchronisation UND die jeweilige Gruppen-ID in der GUI +# (Verwaltung > Automatisierung > Kalender-Sync) aktiviert/gesetzt werden - +# diese drei Env-Variablen allein reichen nicht. +M365_CALENDAR_TENANT_ID="change-me-m365-tenant-id" +M365_CALENDAR_CLIENT_ID="change-me-m365-client-id" +M365_CALENDAR_CLIENT_SECRET="change-me-m365-client-secret" + # Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das # gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde. # Nur fuer lokale Entwicklung/Debugging explizit aktivieren. diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index 2a45cea..80b5655 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -92,7 +92,7 @@ "^.+\\.(t|j)s$": "ts-jest" }, "transformIgnorePatterns": [ - "node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose)/)" + "node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)" ], "collectCoverageFrom": [ "**/*.(t|j)s" diff --git a/ApiCore-Backend/prisma/migrations/20260904090000_add_calendar_sync/migration.sql b/ApiCore-Backend/prisma/migrations/20260904090000_add_calendar_sync/migration.sql new file mode 100644 index 0000000..2958b9a --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260904090000_add_calendar_sync/migration.sql @@ -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"); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 0ce37c8..477c341 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -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]) +} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 36b085f..04d3272 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -29,6 +29,7 @@ import { ProductsModule } from './products/products.module'; import { BottlingModule } from './bottling/bottling.module'; import { EquipmentModule } from './equipment/equipment.module'; import { CalendarModule } from './calendar/calendar.module'; +import { CalendarSyncModule } from './calendar-sync/calendar-sync.module'; import { NotificationsModule } from './notifications/notifications.module'; import { StatisticsModule } from './statistics/statistics.module'; import { WebhooksModule } from './webhooks/webhooks.module'; @@ -79,6 +80,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module'; BottlingModule, EquipmentModule, CalendarModule, + CalendarSyncModule, NotificationsModule, StatisticsModule, WebhooksModule, diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 84f26aa..1e8501e 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -271,7 +271,15 @@ export const PERMISSION_CATALOG = [ 2400, ), ...group('Audit-Log', 'audit', [['read', 'Ansehen']], 2500), - ...group('Kalender', 'calendar', [['read', 'Ansehen']], 2600), + ...group( + 'Kalender', + 'calendar', + [ + ['read', 'Ansehen'], + ['configure', 'M365-Synchronisation konfigurieren'], + ], + 2600, + ), ...group('Dashboard', 'dashboard', [['read', 'Ansehen']], 2700), ...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800), ...group('Suche', 'search', [['read', 'Ansehen']], 2900), diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts new file mode 100644 index 0000000..97aa31c --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts @@ -0,0 +1,139 @@ +import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('CalendarSyncSettingsService', () => { + let service: CalendarSyncSettingsService; + let prisma: { + calendarSyncSettings: { + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + + const existing = { + id: 'singleton', + enabled: false, + groupId: null as string | null, + syncTask: true, + syncInspection: true, + syncTreatment: true, + syncFeeding: true, + syncHarvest: true, + syncQueenAssignment: true, + updatedAt: new Date('2026-01-01'), + }; + + beforeEach(() => { + prisma = { + calendarSyncSettings: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + service = new CalendarSyncSettingsService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + describe('get', () => { + it('returns the existing singleton row', async () => { + prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing); + + const result = await service.get(); + + expect(result).toEqual(existing); + expect(prisma.calendarSyncSettings.create).not.toHaveBeenCalled(); + }); + + it('creates the singleton row on first access', async () => { + prisma.calendarSyncSettings.findUnique.mockResolvedValue(null); + prisma.calendarSyncSettings.create.mockResolvedValue({ + ...existing, + }); + + const result = await service.get(); + + expect(prisma.calendarSyncSettings.create).toHaveBeenCalledWith({ + data: { id: 'singleton' }, + }); + expect(result.id).toBe('singleton'); + }); + }); + + describe('update', () => { + it('stores a groupId and records an audit entry', async () => { + prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing); + prisma.calendarSyncSettings.update.mockResolvedValue({ + ...existing, + groupId: 'group-abc', + }); + + const result = await service.update({ groupId: 'group-abc' }, 'user-1'); + + expect(prisma.calendarSyncSettings.update).toHaveBeenCalledWith({ + where: { id: 'singleton' }, + data: { + enabled: undefined, + groupId: 'group-abc', + syncTask: undefined, + syncInspection: undefined, + syncTreatment: undefined, + syncFeeding: undefined, + syncHarvest: undefined, + syncQueenAssignment: undefined, + }, + }); + expect(result.groupId).toBe('group-abc'); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'CalendarSyncSettings', + action: 'UPDATE', + userId: 'user-1', + }), + ); + }); + + it('clears groupId when given an empty string', async () => { + prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing); + prisma.calendarSyncSettings.update.mockResolvedValue({ + ...existing, + groupId: null, + }); + + await service.update({ groupId: '' }, 'user-1'); + + expect(prisma.calendarSyncSettings.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ groupId: null }) as unknown, + }), + ); + }); + + it('leaves fields untouched when omitted from the dto', async () => { + prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing); + prisma.calendarSyncSettings.update.mockResolvedValue(existing); + + await service.update({}, 'user-1'); + + expect(prisma.calendarSyncSettings.update).toHaveBeenCalledWith({ + where: { id: 'singleton' }, + data: { + enabled: undefined, + groupId: undefined, + syncTask: undefined, + syncInspection: undefined, + syncTreatment: undefined, + syncFeeding: undefined, + syncHarvest: undefined, + syncQueenAssignment: undefined, + }, + }); + }); + }); +}); diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts new file mode 100644 index 0000000..7540677 --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { CalendarSyncSettings } from '../../generated/prisma'; +import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settings.dto'; + +const SINGLETON_ID = 'singleton'; + +function emptyToNull(value: string | undefined): string | null { + return value === '' ? null : (value ?? null); +} + +@Injectable() +export class CalendarSyncSettingsService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async get(): Promise { + const existing = await this.prisma.calendarSyncSettings.findUnique({ + where: { id: SINGLETON_ID }, + }); + if (existing) { + return existing; + } + return this.prisma.calendarSyncSettings.create({ + data: { id: SINGLETON_ID }, + }); + } + + async update( + dto: UpdateCalendarSyncSettingsDto, + actingUserId: string, + ): Promise { + const before = await this.get(); + const settings = await this.prisma.calendarSyncSettings.update({ + where: { id: SINGLETON_ID }, + data: { + enabled: dto.enabled, + groupId: + dto.groupId !== undefined ? emptyToNull(dto.groupId) : undefined, + syncTask: dto.syncTask, + syncInspection: dto.syncInspection, + syncTreatment: dto.syncTreatment, + syncFeeding: dto.syncFeeding, + syncHarvest: dto.syncHarvest, + syncQueenAssignment: dto.syncQueenAssignment, + }, + }); + await this.auditLog.record({ + entityType: 'CalendarSyncSettings', + entityId: SINGLETON_ID, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: settings, + }); + return settings; + } +} diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts new file mode 100644 index 0000000..367e583 --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; +import { CalendarSyncService } from './calendar-sync.service'; +import { UpdateCalendarSyncSettingsDto } from './dto/update-calendar-sync-settings.dto'; + +@ApiTags('calendar-sync') +@ApiBearerAuth() +@Controller('calendar/sync') +export class CalendarSyncController { + constructor( + private readonly calendarSyncSettingsService: CalendarSyncSettingsService, + private readonly calendarSyncService: CalendarSyncService, + ) {} + + @RequirePermission('calendar:configure') + @Get('settings') + getSettings() { + return this.calendarSyncSettingsService.get(); + } + + @RequirePermission('calendar:configure') + @Patch('settings') + updateSettings( + @Body() dto: UpdateCalendarSyncSettingsDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.calendarSyncSettingsService.update(dto, actor.id); + } + + @RequirePermission('calendar:configure') + @Get('status') + getStatus() { + return this.calendarSyncService.getStatus(); + } + + @RequirePermission('calendar:configure') + @Post('run-now') + async runNow() { + await this.calendarSyncService.runNow(); + return this.calendarSyncService.getStatus(); + } +} diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts new file mode 100644 index 0000000..99e3adb --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts @@ -0,0 +1,21 @@ +import { Global, Module } from '@nestjs/common'; +import { CalendarSyncController } from './calendar-sync.controller'; +import { CalendarSyncService } from './calendar-sync.service'; +import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; +import { GraphClientService } from './graph-client.service'; + +// @Global(): CalendarSyncService.enqueue()/cancel() are called from ~6 +// otherwise-unrelated domain modules (Tasks, Inspections, Treatments, +// Feedings, Harvests, Queens) - matches WebhooksModule's own @Global() +// rationale rather than having each of those six modules import this one. +@Global() +@Module({ + controllers: [CalendarSyncController], + providers: [ + CalendarSyncService, + CalendarSyncSettingsService, + GraphClientService, + ], + exports: [CalendarSyncService], +}) +export class CalendarSyncModule {} diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts new file mode 100644 index 0000000..ff8e85c --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts @@ -0,0 +1,372 @@ +import { CalendarSyncService } from './calendar-sync.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; +import { GraphClientService } from './graph-client.service'; + +describe('CalendarSyncService', () => { + let service: CalendarSyncService; + let prisma: { + calendarSyncOutboxEntry: { + upsert: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + findMany: jest.Mock; + count: jest.Mock; + }; + task: { findUnique: jest.Mock }; + inspection: { findUnique: jest.Mock }; + treatment: { findUnique: jest.Mock }; + feeding: { findUnique: jest.Mock }; + harvest: { findUnique: jest.Mock }; + queenColonyHistory: { findUnique: jest.Mock }; + }; + let settingsService: { get: jest.Mock }; + let graphClient: { + isConfigured: jest.Mock; + createEvent: jest.Mock; + updateEvent: jest.Mock; + deleteEvent: jest.Mock; + }; + + const enabledSettings = { + id: 'singleton', + enabled: true, + groupId: 'group-1', + syncTask: true, + syncInspection: true, + syncTreatment: true, + syncFeeding: true, + syncHarvest: true, + syncQueenAssignment: true, + updatedAt: new Date(), + }; + + beforeEach(() => { + prisma = { + calendarSyncOutboxEntry: { + upsert: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + findMany: jest.fn(), + count: jest.fn().mockResolvedValue(0), + }, + task: { findUnique: jest.fn() }, + inspection: { findUnique: jest.fn() }, + treatment: { findUnique: jest.fn() }, + feeding: { findUnique: jest.fn() }, + harvest: { findUnique: jest.fn() }, + queenColonyHistory: { findUnique: jest.fn() }, + }; + settingsService = { get: jest.fn().mockResolvedValue(enabledSettings) }; + graphClient = { + isConfigured: jest.fn().mockReturnValue(true), + createEvent: jest.fn(), + updateEvent: jest.fn(), + deleteEvent: jest.fn(), + }; + service = new CalendarSyncService( + prisma as unknown as PrismaService, + settingsService as unknown as CalendarSyncSettingsService, + graphClient as unknown as GraphClientService, + ); + }); + + describe('enqueue', () => { + it('does nothing when sync is disabled overall', async () => { + settingsService.get.mockResolvedValue({ + ...enabledSettings, + enabled: false, + }); + + await service.enqueue('TASK', 'task-1'); + + expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); + }); + + it('does nothing when the source type toggle is off', async () => { + settingsService.get.mockResolvedValue({ + ...enabledSettings, + syncTask: false, + }); + + await service.enqueue('TASK', 'task-1'); + + expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); + }); + + it('upserts a PENDING row, resetting attempts and lastError', async () => { + await service.enqueue('TASK', 'task-1'); + + expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({ + where: { + sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' }, + }, + create: { sourceType: 'TASK', sourceId: 'task-1' }, + update: { status: 'PENDING', attempts: 0, lastError: null }, + }); + }); + }); + + describe('cancel', () => { + it('does nothing when no outbox row exists', async () => { + prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null); + + await service.cancel('TASK', 'task-1'); + + expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled(); + }); + + it('deletes the Graph event and clears the row when a graphEventId exists', async () => { + prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({ + id: 'entry-1', + graphEventId: 'graph-event-1', + }); + + await service.cancel('TASK', 'task-1'); + + expect(graphClient.deleteEvent).toHaveBeenCalledWith( + 'group-1', + 'graph-event-1', + ); + expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ + where: { id: 'entry-1' }, + data: { status: 'SKIPPED', graphEventId: null }, + }); + }); + + it('still clears the row when the Graph delete fails', async () => { + prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({ + id: 'entry-1', + graphEventId: 'graph-event-1', + }); + graphClient.deleteEvent.mockRejectedValue(new Error('network down')); + + await service.cancel('TASK', 'task-1'); + + expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ + where: { id: 'entry-1' }, + data: { status: 'SKIPPED', graphEventId: null }, + }); + }); + }); + + describe('processOutbox', () => { + it('does nothing when sync is disabled, unconfigured, or no groupId', async () => { + settingsService.get.mockResolvedValue({ + ...enabledSettings, + enabled: false, + }); + await service.processOutbox(); + expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled(); + }); + + it('creates a Graph event for a new TASK entry and marks it SYNCED', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + { + id: 'entry-1', + sourceType: 'TASK', + sourceId: 'task-1', + graphEventId: null, + attempts: 0, + }, + ]); + prisma.task.findUnique.mockResolvedValue({ + id: 'task-1', + title: 'Beute prüfen', + dueDate: new Date('2026-03-07'), + }); + graphClient.createEvent.mockResolvedValue('graph-event-1'); + + await service.processOutbox(); + + expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', { + title: 'Beute prüfen', + date: new Date('2026-03-07'), + }); + expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ + where: { id: 'entry-1' }, + data: { + status: 'SYNCED', + graphEventId: 'graph-event-1', + lastError: null, + }, + }); + }); + + it('marks the entry SKIPPED when the Task no longer has a dueDate', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + { + id: 'entry-1', + sourceType: 'TASK', + sourceId: 'task-1', + graphEventId: null, + attempts: 0, + }, + ]); + prisma.task.findUnique.mockResolvedValue({ + id: 'task-1', + title: 'Beute prüfen', + dueDate: null, + }); + + await service.processOutbox(); + + expect(graphClient.createEvent).not.toHaveBeenCalled(); + expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ + where: { id: 'entry-1' }, + data: { status: 'SKIPPED' }, + }); + }); + + it('updates an existing Graph event when graphEventId is already set', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + { + id: 'entry-1', + sourceType: 'TASK', + sourceId: 'task-1', + graphEventId: 'graph-event-1', + attempts: 0, + }, + ]); + prisma.task.findUnique.mockResolvedValue({ + id: 'task-1', + title: 'Beute prüfen (geändert)', + dueDate: new Date('2026-03-10'), + }); + graphClient.updateEvent.mockResolvedValue(undefined); + + await service.processOutbox(); + + expect(graphClient.updateEvent).toHaveBeenCalledWith( + 'group-1', + 'graph-event-1', + { title: 'Beute prüfen (geändert)', date: new Date('2026-03-10') }, + ); + expect(graphClient.createEvent).not.toHaveBeenCalled(); + expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ + where: { id: 'entry-1' }, + data: { + status: 'SYNCED', + graphEventId: 'graph-event-1', + lastError: null, + }, + }); + }); + + it('falls back to creating a fresh event when updating a stale graphEventId 404s', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + { + id: 'entry-1', + sourceType: 'TASK', + sourceId: 'task-1', + graphEventId: 'deleted-on-m365', + attempts: 0, + }, + ]); + prisma.task.findUnique.mockResolvedValue({ + id: 'task-1', + title: 'Beute prüfen', + dueDate: new Date('2026-03-07'), + }); + graphClient.updateEvent.mockRejectedValue(new Error('404 Not Found')); + graphClient.createEvent.mockResolvedValue('graph-event-new'); + + await service.processOutbox(); + + expect(graphClient.createEvent).toHaveBeenCalled(); + expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ + where: { id: 'entry-1' }, + data: { + status: 'SYNCED', + graphEventId: 'graph-event-new', + lastError: null, + }, + }); + }); + + it('retries (stays PENDING) on failure below the max attempt count', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + { + id: 'entry-1', + sourceType: 'TASK', + sourceId: 'task-1', + graphEventId: null, + attempts: 1, + }, + ]); + prisma.task.findUnique.mockRejectedValue(new Error('db error')); + + await service.processOutbox(); + + expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ + where: { id: 'entry-1' }, + data: { status: 'PENDING', attempts: 2, lastError: 'db error' }, + }); + }); + + it('marks FAILED once the max attempt count is reached', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + { + id: 'entry-1', + sourceType: 'TASK', + sourceId: 'task-1', + graphEventId: null, + attempts: 4, + }, + ]); + prisma.task.findUnique.mockRejectedValue(new Error('db error')); + + await service.processOutbox(); + + expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({ + where: { id: 'entry-1' }, + data: { status: 'FAILED', attempts: 5, lastError: 'db error' }, + }); + }); + + it('builds titles per source type matching the read-only calendar view', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + { + id: 'e1', + sourceType: 'INSPECTION', + sourceId: 'i1', + graphEventId: null, + attempts: 0, + }, + ]); + prisma.inspection.findUnique.mockResolvedValue({ + id: 'i1', + date: new Date('2026-03-07'), + colony: { code: 'V-001' }, + }); + graphClient.createEvent.mockResolvedValue('g1'); + + await service.processOutbox(); + + expect(graphClient.createEvent).toHaveBeenCalledWith('group-1', { + title: 'Durchsicht V-001', + date: new Date('2026-03-07'), + }); + }); + }); + + describe('getStatus', () => { + it('reports configured/enabled/groupId plus outbox counts', async () => { + prisma.calendarSyncOutboxEntry.count + .mockResolvedValueOnce(2) // PENDING + .mockResolvedValueOnce(1) // FAILED + .mockResolvedValueOnce(5); // SYNCED + + const status = await service.getStatus(); + + expect(status).toEqual({ + configured: true, + enabled: true, + groupId: 'group-1', + pendingCount: 2, + failedCount: 1, + syncedCount: 5, + }); + }); + }); +}); diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts new file mode 100644 index 0000000..7767315 --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts @@ -0,0 +1,302 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; +import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; +import { + GraphClientService, + GraphCalendarEventInput, +} from './graph-client.service'; +import { + CalendarSyncOutboxEntry, + CalendarSyncSettings, + CalendarSyncSourceType, +} from '../../generated/prisma'; + +// Bounded per tick so a large backlog can't turn one cron run into a +// long-running request that overlaps the next tick. +const BATCH_SIZE = 20; +// A row that keeps failing this many times stops retrying automatically +// (status becomes FAILED) - "Jetzt synchronisieren" or a fresh enqueue +// (e.g. editing the source record) resets it back to PENDING. +const MAX_ATTEMPTS = 5; + +export interface CalendarSyncStatusReport { + configured: boolean; + enabled: boolean; + groupId: string | null; + pendingCount: number; + failedCount: number; + syncedCount: number; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +@Injectable() +export class CalendarSyncService { + private readonly logger = new Logger(CalendarSyncService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly settingsService: CalendarSyncSettingsService, + private readonly graphClient: GraphClientService, + ) {} + + private isSourceTypeEnabled( + settings: CalendarSyncSettings, + sourceType: CalendarSyncSourceType, + ): boolean { + const flagBySourceType: Record = { + TASK: settings.syncTask, + INSPECTION: settings.syncInspection, + TREATMENT: settings.syncTreatment, + FEEDING: settings.syncFeeding, + HARVEST: settings.syncHarvest, + QUEEN_ASSIGNMENT: settings.syncQueenAssignment, + }; + return flagBySourceType[sourceType]; + } + + // Called from the ~6 domain services right after a create/update that + // should appear on the M365 calendar. Upserts rather than always + // inserting: re-enqueuing an already-synced row (e.g. a Task's due date + // changed) resets it to PENDING while keeping its graphEventId, so the + // worker updates the existing Graph event instead of creating a duplicate. + async enqueue( + sourceType: CalendarSyncSourceType, + sourceId: string, + ): Promise { + const settings = await this.settingsService.get(); + if (!settings.enabled) { + return; + } + if (!this.isSourceTypeEnabled(settings, sourceType)) { + return; + } + await this.prisma.calendarSyncOutboxEntry.upsert({ + where: { sourceType_sourceId: { sourceType, sourceId } }, + create: { sourceType, sourceId }, + update: { status: 'PENDING', attempts: 0, lastError: null }, + }); + } + + // Called when a source record no longer belongs on the calendar (e.g. a + // Task is completed). Best-effort: a failed Graph delete is logged, not + // thrown, since the outbox row is cleared either way and a future enqueue + // for the same (sourceType, sourceId) will simply create a fresh event. + async cancel( + sourceType: CalendarSyncSourceType, + sourceId: string, + ): Promise { + const entry = await this.prisma.calendarSyncOutboxEntry.findUnique({ + where: { sourceType_sourceId: { sourceType, sourceId } }, + }); + if (!entry) { + return; + } + if (entry.graphEventId) { + const settings = await this.settingsService.get(); + if ( + settings.enabled && + settings.groupId && + this.graphClient.isConfigured() + ) { + try { + await this.graphClient.deleteEvent( + settings.groupId, + entry.graphEventId, + ); + } catch (error: unknown) { + this.logger.warn( + `Could not delete Graph event ${entry.graphEventId}: ${getErrorMessage(error)}`, + ); + } + } + } + await this.prisma.calendarSyncOutboxEntry.update({ + where: { id: entry.id }, + data: { status: 'SKIPPED', graphEventId: null }, + }); + } + + @Cron(CronExpression.EVERY_MINUTE) + async processOutbox(): Promise { + const settings = await this.settingsService.get(); + if ( + !settings.enabled || + !settings.groupId || + !this.graphClient.isConfigured() + ) { + return; + } + + const entries = await this.prisma.calendarSyncOutboxEntry.findMany({ + where: { status: 'PENDING' }, + orderBy: { updatedAt: 'asc' }, + take: BATCH_SIZE, + }); + for (const entry of entries) { + await this.processEntry(entry, settings.groupId); + } + } + + async runNow(): Promise { + await this.processOutbox(); + } + + private async processEntry( + entry: CalendarSyncOutboxEntry, + groupId: string, + ): Promise { + try { + const input = await this.buildEventInput( + entry.sourceType, + entry.sourceId, + ); + if (!input) { + await this.prisma.calendarSyncOutboxEntry.update({ + where: { id: entry.id }, + data: { status: 'SKIPPED' }, + }); + return; + } + + const graphEventId = entry.graphEventId + ? await this.updateExisting(groupId, entry.graphEventId, input) + : await this.graphClient.createEvent(groupId, input); + + await this.prisma.calendarSyncOutboxEntry.update({ + where: { id: entry.id }, + data: { status: 'SYNCED', graphEventId, lastError: null }, + }); + } catch (error: unknown) { + const attempts = entry.attempts + 1; + await this.prisma.calendarSyncOutboxEntry.update({ + where: { id: entry.id }, + data: { + status: attempts >= MAX_ATTEMPTS ? 'FAILED' : 'PENDING', + attempts, + lastError: getErrorMessage(error), + }, + }); + } + } + + // PATCH on an existing event id that was deleted on the M365 side (e.g. an + // admin removed it manually) would 404 forever - fall back to creating a + // fresh event instead of leaving the row permanently FAILED. + private async updateExisting( + groupId: string, + graphEventId: string, + input: GraphCalendarEventInput, + ): Promise { + try { + await this.graphClient.updateEvent(groupId, graphEventId, input); + return graphEventId; + } catch { + return this.graphClient.createEvent(groupId, input); + } + } + + private async buildEventInput( + sourceType: CalendarSyncSourceType, + sourceId: string, + ): Promise { + switch (sourceType) { + case 'TASK': { + const task = await this.prisma.task.findUnique({ + where: { id: sourceId }, + }); + if (!task || !task.dueDate) { + return null; + } + return { title: task.title, date: task.dueDate }; + } + case 'INSPECTION': { + const inspection = await this.prisma.inspection.findUnique({ + where: { id: sourceId }, + include: { colony: true }, + }); + if (!inspection) { + return null; + } + return { + title: `Durchsicht ${inspection.colony.code}`, + date: inspection.date, + }; + } + case 'TREATMENT': { + const treatment = await this.prisma.treatment.findUnique({ + where: { id: sourceId }, + include: { colony: true }, + }); + if (!treatment) { + return null; + } + return { + title: `Behandlung ${treatment.colony.code} · ${treatment.product}`, + date: treatment.date, + }; + } + case 'FEEDING': { + const feeding = await this.prisma.feeding.findUnique({ + where: { id: sourceId }, + include: { colony: true }, + }); + if (!feeding) { + return null; + } + return { + title: `Fütterung ${feeding.colony.code}`, + date: feeding.date, + }; + } + case 'HARVEST': { + const harvest = await this.prisma.harvest.findUnique({ + where: { id: sourceId }, + }); + if (!harvest) { + return null; + } + return { title: 'Ernte', date: harvest.date }; + } + case 'QUEEN_ASSIGNMENT': { + const assignment = await this.prisma.queenColonyHistory.findUnique({ + where: { id: sourceId }, + include: { colony: true, queen: true }, + }); + if (!assignment) { + return null; + } + return { + title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`, + date: assignment.startDate, + }; + } + } + } + + async getStatus(): Promise { + const settings = await this.settingsService.get(); + const [pendingCount, failedCount, syncedCount] = await Promise.all([ + this.prisma.calendarSyncOutboxEntry.count({ + where: { status: 'PENDING' }, + }), + this.prisma.calendarSyncOutboxEntry.count({ + where: { status: 'FAILED' }, + }), + this.prisma.calendarSyncOutboxEntry.count({ + where: { status: 'SYNCED' }, + }), + ]); + return { + configured: this.graphClient.isConfigured(), + enabled: settings.enabled, + groupId: settings.groupId, + pendingCount, + failedCount, + syncedCount, + }; + } +} diff --git a/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts b/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts new file mode 100644 index 0000000..0da4d9f --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts @@ -0,0 +1,48 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateCalendarSyncSettingsDto { + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @ApiPropertyOptional({ + description: + 'Objekt-ID der Microsoft-365-Gruppe. Leerer String löscht den GUI-Wert.', + }) + @IsOptional() + @IsString() + @MaxLength(100) + groupId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + syncTask?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + syncInspection?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + syncTreatment?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + syncFeeding?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + syncHarvest?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + syncQueenAssignment?: boolean; +} diff --git a/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts b/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts new file mode 100644 index 0000000..deb93f9 --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts @@ -0,0 +1,130 @@ +import { GraphClientService } from './graph-client.service'; + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + } as Response; +} + +describe('GraphClientService', () => { + let service: GraphClientService; + let fetchMock: jest.Mock; + + beforeEach(() => { + service = new GraphClientService(); + fetchMock = jest.fn(); + global.fetch = fetchMock; + process.env.M365_CALENDAR_TENANT_ID = 'tenant-1'; + process.env.M365_CALENDAR_CLIENT_ID = 'client-1'; + process.env.M365_CALENDAR_CLIENT_SECRET = 'secret-1'; + }); + + afterEach(() => { + delete process.env.M365_CALENDAR_TENANT_ID; + delete process.env.M365_CALENDAR_CLIENT_ID; + delete process.env.M365_CALENDAR_CLIENT_SECRET; + }); + + describe('isConfigured', () => { + it('returns true when all three env vars are set', () => { + expect(service.isConfigured()).toBe(true); + }); + + it('returns false when a required env var is missing', () => { + delete process.env.M365_CALENDAR_CLIENT_SECRET; + expect(service.isConfigured()).toBe(false); + }); + }); + + describe('createEvent', () => { + it('fetches a token, then posts an all-day event and returns the new id', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), + ) + .mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' })); + + const result = await service.createEvent('group-1', { + title: 'Durchsicht V-001', + date: new Date(Date.UTC(2026, 2, 7)), + }); + + expect(result).toBe('graph-event-1'); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [tokenUrl] = fetchMock.mock.calls[0] as [string]; + expect(tokenUrl).toBe( + 'https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token', + ); + const [eventUrl, eventInit] = fetchMock.mock.calls[1] as [ + string, + { headers: Record; body: string }, + ]; + expect(eventUrl).toBe( + 'https://graph.microsoft.com/v1.0/groups/group-1/events', + ); + expect(eventInit.headers.Authorization).toBe('Bearer tok-1'); + const body = JSON.parse(eventInit.body) as { + subject: string; + isAllDay: boolean; + start: { dateTime: string }; + end: { dateTime: string }; + }; + expect(body.subject).toBe('Durchsicht V-001'); + expect(body.isAllDay).toBe(true); + expect(body.start.dateTime).toBe('2026-03-07T00:00:00.0000000'); + expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000'); + }); + + it('caches the token and does not fetch it again for a second call', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), + ) + .mockResolvedValueOnce(jsonResponse(201, { id: 'event-a' })) + .mockResolvedValueOnce(jsonResponse(201, { id: 'event-b' })); + + await service.createEvent('group-1', { + title: 'A', + date: new Date(Date.UTC(2026, 2, 7)), + }); + await service.createEvent('group-1', { + title: 'B', + date: new Date(Date.UTC(2026, 2, 8)), + }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('throws with the response body when Graph returns an error status', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), + ) + .mockResolvedValueOnce(jsonResponse(403, { error: 'Forbidden' })); + + await expect( + service.createEvent('group-1', { + title: 'A', + date: new Date(Date.UTC(2026, 2, 7)), + }), + ).rejects.toThrow(/createEvent fehlgeschlagen \(403\)/); + }); + }); + + describe('deleteEvent', () => { + it('treats a 404 as success (idempotent delete)', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), + ) + .mockResolvedValueOnce(jsonResponse(404, {})); + + await expect( + service.deleteEvent('group-1', 'missing-event'), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/ApiCore-Backend/src/calendar-sync/graph-client.service.ts b/ApiCore-Backend/src/calendar-sync/graph-client.service.ts new file mode 100644 index 0000000..b8fc30e --- /dev/null +++ b/ApiCore-Backend/src/calendar-sync/graph-client.service.ts @@ -0,0 +1,174 @@ +import { Injectable } from '@nestjs/common'; + +export interface GraphCalendarEventInput { + title: string; + date: Date; +} + +interface CachedToken { + accessToken: string; + expiresAt: number; +} + +const GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'; +// All-day events render on the correct date regardless of viewer timezone, +// but Graph still requires a timeZone on the start/end payload - fixed to +// the org's own zone since this app has no per-user timezone setting. +const EVENT_TIME_ZONE = 'Europe/Berlin'; +// Refresh a little before actual expiry so a request never races an +// about-to-expire cached token. +const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000; + +function tokenEndpoint(tenantId: string): string { + return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +@Injectable() +export class GraphClientService { + private cachedToken: CachedToken | null = null; + + isConfigured(): boolean { + return Boolean( + process.env.M365_CALENDAR_TENANT_ID && + process.env.M365_CALENDAR_CLIENT_ID && + process.env.M365_CALENDAR_CLIENT_SECRET, + ); + } + + private async getAccessToken(): Promise { + if (this.cachedToken && this.cachedToken.expiresAt > Date.now()) { + return this.cachedToken.accessToken; + } + + const tenantId = process.env.M365_CALENDAR_TENANT_ID; + const clientId = process.env.M365_CALENDAR_CLIENT_ID; + const clientSecret = process.env.M365_CALENDAR_CLIENT_SECRET; + if (!tenantId || !clientId || !clientSecret) { + throw new Error( + 'M365-Kalender-Synchronisation ist nicht konfiguriert (Env-Variablen fehlen)', + ); + } + + let response: Response; + try { + response = await fetch(tokenEndpoint(tenantId), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + scope: 'https://graph.microsoft.com/.default', + }), + }); + } catch (error: unknown) { + throw new Error( + `Token-Anfrage an Microsoft fehlgeschlagen: ${getErrorMessage(error)}`, + ); + } + if (!response.ok) { + throw new Error( + `Token-Anfrage an Microsoft fehlgeschlagen (${response.status}): ${await response.text()}`, + ); + } + + const data = (await response.json()) as { + access_token: string; + expires_in: number; + }; + this.cachedToken = { + accessToken: data.access_token, + expiresAt: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_BUFFER_MS, + }; + return this.cachedToken.accessToken; + } + + private allDayEventBody( + input: GraphCalendarEventInput, + ): Record { + const day = input.date.toISOString().slice(0, 10); + const nextDay = new Date(input.date); + nextDay.setUTCDate(nextDay.getUTCDate() + 1); + const nextDayString = nextDay.toISOString().slice(0, 10); + return { + subject: input.title, + isAllDay: true, + start: { + dateTime: `${day}T00:00:00.0000000`, + timeZone: EVENT_TIME_ZONE, + }, + end: { + dateTime: `${nextDayString}T00:00:00.0000000`, + timeZone: EVENT_TIME_ZONE, + }, + }; + } + + async createEvent( + groupId: string, + input: GraphCalendarEventInput, + ): Promise { + const token = await this.getAccessToken(); + const response = await fetch(`${GRAPH_BASE_URL}/groups/${groupId}/events`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(this.allDayEventBody(input)), + }); + if (!response.ok) { + throw new Error( + `Graph createEvent fehlgeschlagen (${response.status}): ${await response.text()}`, + ); + } + const data = (await response.json()) as { id: string }; + return data.id; + } + + async updateEvent( + groupId: string, + eventId: string, + input: GraphCalendarEventInput, + ): Promise { + const token = await this.getAccessToken(); + const response = await fetch( + `${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(this.allDayEventBody(input)), + }, + ); + if (!response.ok) { + throw new Error( + `Graph updateEvent fehlgeschlagen (${response.status}): ${await response.text()}`, + ); + } + } + + async deleteEvent(groupId: string, eventId: string): Promise { + const token = await this.getAccessToken(); + const response = await fetch( + `${GRAPH_BASE_URL}/groups/${groupId}/events/${eventId}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }, + ); + // A 404 means the event is already gone - treat as success so a retry + // after a partially-applied delete doesn't throw. + if (!response.ok && response.status !== 404) { + throw new Error( + `Graph deleteEvent fehlgeschlagen (${response.status}): ${await response.text()}`, + ); + } + } +} diff --git a/ApiCore-Backend/src/feedings/feedings.service.spec.ts b/ApiCore-Backend/src/feedings/feedings.service.spec.ts index 7e3f4fc..6a58c00 100644 --- a/ApiCore-Backend/src/feedings/feedings.service.spec.ts +++ b/ApiCore-Backend/src/feedings/feedings.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { FeedingsService } from './feedings.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; describe('FeedingsService', () => { let service: FeedingsService; @@ -29,9 +30,11 @@ describe('FeedingsService', () => { }, }; auditLog = { record: jest.fn() }; + const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; service = new FeedingsService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + calendarSync as unknown as CalendarSyncService, ); }); diff --git a/ApiCore-Backend/src/feedings/feedings.service.ts b/ApiCore-Backend/src/feedings/feedings.service.ts index 8a00462..33eaa90 100644 --- a/ApiCore-Backend/src/feedings/feedings.service.ts +++ b/ApiCore-Backend/src/feedings/feedings.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { Feeding } from '../../generated/prisma'; import { CreateFeedingDto } from './dto/create-feeding.dto'; import { UpdateFeedingDto } from './dto/update-feeding.dto'; @@ -15,6 +16,7 @@ export class FeedingsService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly calendarSync: CalendarSyncService, ) {} async findAll(): Promise { @@ -59,6 +61,7 @@ export class FeedingsService { userId: actingUserId, afterData: feeding, }); + await this.calendarSync.enqueue('FEEDING', feeding.id); return feeding; } diff --git a/ApiCore-Backend/src/harvests/harvests.service.spec.ts b/ApiCore-Backend/src/harvests/harvests.service.spec.ts index 961b1ef..72b0422 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.spec.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.spec.ts @@ -3,6 +3,7 @@ import { HarvestsService } from './harvests.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; describe('HarvestsService', () => { let service: HarvestsService; @@ -37,10 +38,12 @@ describe('HarvestsService', () => { }; auditLog = { record: jest.fn() }; const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; + const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; service = new HarvestsService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, webhooks as unknown as WebhooksService, + calendarSync as unknown as CalendarSyncService, ); }); diff --git a/ApiCore-Backend/src/harvests/harvests.service.ts b/ApiCore-Backend/src/harvests/harvests.service.ts index d000ec1..58299a3 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.ts @@ -6,6 +6,7 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { CreateHarvestDto } from './dto/create-harvest.dto'; import { UpdateHarvestDto } from './dto/update-harvest.dto'; @@ -19,6 +20,7 @@ export class HarvestsService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, + private readonly calendarSync: CalendarSyncService, ) {} async findAll() { @@ -85,6 +87,7 @@ export class HarvestsService { }); const created = await this.findById(harvest.id); await this.webhooks.dispatch('harvest.created', created); + await this.calendarSync.enqueue('HARVEST', harvest.id); return created; } diff --git a/ApiCore-Backend/src/inspections/inspections.service.spec.ts b/ApiCore-Backend/src/inspections/inspections.service.spec.ts index 51d85bb..220ffda 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.spec.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.spec.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { AutomationService } from '../automation/automation.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; describe('InspectionsService', () => { let service: InspectionsService; @@ -39,11 +40,13 @@ describe('InspectionsService', () => { evaluateInspection: jest.fn().mockResolvedValue(undefined), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; service = new InspectionsService( prisma as unknown as PrismaService, webhooks as unknown as WebhooksService, automation as unknown as AutomationService, auditLog as unknown as AuditLogService, + calendarSync as unknown as CalendarSyncService, ); }); diff --git a/ApiCore-Backend/src/inspections/inspections.service.ts b/ApiCore-Backend/src/inspections/inspections.service.ts index 3330c79..fd7f6db 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { AutomationService } from '../automation/automation.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { Inspection, Task } from '../../generated/prisma'; import { CreateInspectionDto } from './dto/create-inspection.dto'; import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; @@ -15,6 +16,7 @@ export class InspectionsService { private readonly webhooks: WebhooksService, private readonly automation: AutomationService, private readonly auditLog: AuditLogService, + private readonly calendarSync: CalendarSyncService, ) {} async findAll(): Promise { @@ -47,6 +49,7 @@ export class InspectionsService { }); await this.webhooks.dispatch('inspection.created', inspection); await this.automation.evaluateInspection(inspection); + await this.calendarSync.enqueue('INSPECTION', inspection.id); return inspection; } diff --git a/ApiCore-Backend/src/queens/queens.service.spec.ts b/ApiCore-Backend/src/queens/queens.service.spec.ts index 8fe870f..2c7f6eb 100644 --- a/ApiCore-Backend/src/queens/queens.service.spec.ts +++ b/ApiCore-Backend/src/queens/queens.service.spec.ts @@ -3,6 +3,7 @@ import { QueensService } from './queens.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; describe('QueensService', () => { let service: QueensService; @@ -48,10 +49,12 @@ describe('QueensService', () => { }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; + const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; service = new QueensService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, webhooks as unknown as WebhooksService, + calendarSync as unknown as CalendarSyncService, ); }); @@ -77,7 +80,7 @@ describe('QueensService', () => { currentQueenId: 'queen-old', }); tx.colony.update.mockResolvedValue({}); - tx.queenColonyHistory.create.mockResolvedValue({}); + tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' }); await service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'); diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index 3c47385..2a89967 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -6,6 +6,7 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { Queen, QueenStatus } from '../../generated/prisma'; import { CreateQueenDto } from './dto/create-queen.dto'; import { UpdateQueenDto } from './dto/update-queen.dto'; @@ -45,6 +46,7 @@ export class QueensService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, + private readonly calendarSync: CalendarSyncService, ) {} async findAll(filter: QueenFilter = {}): Promise { @@ -118,7 +120,7 @@ export class QueensService { throw new NotFoundException('Colony not found'); } - await this.prisma.$transaction(async (tx) => { + const history = await this.prisma.$transaction(async (tx) => { if (colony.currentQueenId && colony.currentQueenId !== queen.id) { await tx.queenColonyHistory.updateMany({ where: { @@ -137,7 +139,7 @@ export class QueensService { where: { id: colony.id }, data: { currentQueenId: queen.id }, }); - await tx.queenColonyHistory.create({ + return tx.queenColonyHistory.create({ data: { queenId: queen.id, colonyId: colony.id, @@ -158,6 +160,7 @@ export class QueensService { const updated = await this.findById(id); await this.webhooks.dispatch('queen.replaced', updated); + await this.calendarSync.enqueue('QUEEN_ASSIGNMENT', history.id); return updated; } diff --git a/ApiCore-Backend/src/tasks/tasks.service.spec.ts b/ApiCore-Backend/src/tasks/tasks.service.spec.ts index b3a2432..0cd773e 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.spec.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.spec.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { TaskTemplatesService } from '../task-templates/task-templates.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; describe('TasksService', () => { let service: TasksService; @@ -34,11 +35,13 @@ describe('TasksService', () => { taskTemplates = { generateDueTasks: jest.fn().mockResolvedValue(undefined), }; + const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; service = new TasksService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, webhooks as unknown as WebhooksService, taskTemplates as unknown as TaskTemplatesService, + calendarSync as unknown as CalendarSyncService, ); }); diff --git a/ApiCore-Backend/src/tasks/tasks.service.ts b/ApiCore-Backend/src/tasks/tasks.service.ts index c125fdc..a509ad7 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { TaskTemplatesService } from '../task-templates/task-templates.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { Task, TaskPriority, TaskStatus } from '../../generated/prisma'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; @@ -19,6 +20,7 @@ export class TasksService { private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, private readonly taskTemplates: TaskTemplatesService, + private readonly calendarSync: CalendarSyncService, ) {} async findAll(filter: TaskFilter): Promise { @@ -53,6 +55,9 @@ export class TasksService { afterData: task, }); await this.webhooks.dispatch('task.created', task); + if (task.dueDate) { + await this.calendarSync.enqueue('TASK', task.id); + } return task; } @@ -77,6 +82,11 @@ export class TasksService { beforeData: before, afterData: task, }); + if (task.dueDate) { + await this.calendarSync.enqueue('TASK', task.id); + } else { + await this.calendarSync.cancel('TASK', task.id); + } return task; } @@ -95,6 +105,7 @@ export class TasksService { afterData: task, }); await this.webhooks.dispatch('task.completed', task); + await this.calendarSync.cancel('TASK', task.id); return task; } } diff --git a/ApiCore-Backend/src/treatments/treatments.service.spec.ts b/ApiCore-Backend/src/treatments/treatments.service.spec.ts index 3fdbe77..d9ef64d 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.spec.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { TreatmentsService } from './treatments.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; describe('TreatmentsService', () => { let service: TreatmentsService; @@ -31,9 +32,11 @@ describe('TreatmentsService', () => { }, }; auditLog = { record: jest.fn() }; + const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; service = new TreatmentsService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + calendarSync as unknown as CalendarSyncService, ); }); diff --git a/ApiCore-Backend/src/treatments/treatments.service.ts b/ApiCore-Backend/src/treatments/treatments.service.ts index f625ac8..1e49a35 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; import { Treatment } from '../../generated/prisma'; import { CreateTreatmentDto } from './dto/create-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto'; @@ -10,6 +11,7 @@ export class TreatmentsService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly calendarSync: CalendarSyncService, ) {} async findAll(): Promise { @@ -51,6 +53,7 @@ export class TreatmentsService { userId: actingUserId, afterData: treatment, }); + await this.calendarSync.enqueue('TREATMENT', treatment.id); return treatment; } diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx new file mode 100644 index 0000000..d79372e --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/calendar-sync-tab.tsx @@ -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>({ + 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) { + event.preventDefault(); + mutation.mutate({ enabled, groupId, ...typeFlags }); + } + + return ( + + + M365-Kalender-Synchronisation + + +
+

+ Ü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. +

+
+ setEnabled(checked === true)} + /> + +
+
+ + setGroupId(event.target.value)} + placeholder="z. B. 3fa85f64-5717-4562-b3fc-2c963f66afa6" + /> +
+
+

Welche Einträge übertragen werden

+ {SYNC_TYPE_FIELDS.map((field) => ( +
+ + setTypeFlags((prev) => ({ ...prev, [field.key]: checked === true })) + } + /> + +
+ ))} +
+ +
+
+
+ ); +} + +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 ( + + + Status + + + {statusQuery.isLoading ? ( +

Lädt…

+ ) : !status?.configured ? ( +

+ Nicht konfiguriert - M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET fehlen als + Umgebungsvariable. +

+ ) : !canRunNow ? ( +

+ Zugangsdaten vorhanden, aber Synchronisation ist deaktiviert oder es ist keine + Gruppen-ID gesetzt. +

+ ) : ( +
+
Status
+
+ Aktiv +
+
Ausstehend
+
{status.pendingCount}
+
Fehlgeschlagen
+
{status.failedCount}
+
Übertragen
+
{status.syncedCount}
+
+ )} + +
+
+ ); +} + +export function CalendarSyncTab() { + const { + data: settings, + isLoading, + isError, + } = useQuery({ + queryKey: ["calendar-sync-settings"], + queryFn: getCalendarSyncSettings, + }); + + return ( +
+ {isLoading ?

Lädt…

: null} + {isError ? ( +

+ Einstellungen konnten nicht geladen werden. +

+ ) : null} + {settings ? : null} + +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx index 1f2b17f..d3ebe2d 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx @@ -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() { @@ -50,6 +58,7 @@ function AutomationPageContent() { {tab === "label-templates" ? : null} {tab === "social-media" ? : null} {tab === "rules" ? : null} + {tab === "calendar-sync" ? : null} ); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/layout.tsx b/ApiCore-Frontend/src/app/(app)/settings/layout.tsx index 2e52f55..3636ae5 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/layout.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/layout.tsx @@ -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 }) { diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx index c812167..e5d1a6c 100644 --- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx +++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx @@ -104,6 +104,7 @@ const ADMIN_GROUP: NavGroup = { "task-templates:read", "label-templates:read", "site-settings:read", + "calendar:configure", ], }, { diff --git a/ApiCore-Frontend/src/lib/calendar-sync/api.ts b/ApiCore-Frontend/src/lib/calendar-sync/api.ts new file mode 100644 index 0000000..d9dc8bb --- /dev/null +++ b/ApiCore-Frontend/src/lib/calendar-sync/api.ts @@ -0,0 +1,29 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { + CalendarSyncSettings, + CalendarSyncStatus, + UpdateCalendarSyncSettingsInput, +} from "./types"; + +export function getCalendarSyncSettings(): Promise { + return apiFetch("/calendar/sync/settings"); +} + +export function updateCalendarSyncSettings( + input: UpdateCalendarSyncSettingsInput, +): Promise { + return apiFetch("/calendar/sync/settings", { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export function getCalendarSyncStatus(): Promise { + return apiFetch("/calendar/sync/status"); +} + +export function runCalendarSyncNow(): Promise { + return apiFetch("/calendar/sync/run-now", { + method: "POST", + }); +} diff --git a/ApiCore-Frontend/src/lib/calendar-sync/types.ts b/ApiCore-Frontend/src/lib/calendar-sync/types.ts new file mode 100644 index 0000000..6137c8b --- /dev/null +++ b/ApiCore-Frontend/src/lib/calendar-sync/types.ts @@ -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; +} diff --git a/deploy/k8s/backend/secret.example.yaml b/deploy/k8s/backend/secret.example.yaml index 7af3e10..6acbaa1 100644 --- a/deploy/k8s/backend/secret.example.yaml +++ b/deploy/k8s/backend/secret.example.yaml @@ -19,3 +19,9 @@ stringData: # fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. OIDC_CLIENT_ID: "change-me-entra-client-id" OIDC_CLIENT_SECRET: "change-me-entra-client-secret" + # Separate App-Registrierung fuer die M365-Kalender-Synchronisation + # (Application-Permission "Calendars.ReadWrite", Admin-Consent noetig) - + # NICHT dieselbe wie OIDC_CLIENT_ID/-SECRET oben. + M365_CALENDAR_TENANT_ID: "change-me-m365-tenant-id" + M365_CALENDAR_CLIENT_ID: "change-me-m365-client-id" + M365_CALENDAR_CLIENT_SECRET: "change-me-m365-client-secret"