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
@@ -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.
|
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.
|
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.
|
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
|
## 7. Kubernetes
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ OIDC_CLIENT_ID="change-me-entra-client-id"
|
|||||||
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
|
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
|
||||||
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
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
|
# Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das
|
||||||
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
||||||
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"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": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
|
|||||||
@@ -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?
|
retentionCount Int?
|
||||||
updatedAt DateTime @updatedAt
|
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])
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { ProductsModule } from './products/products.module';
|
|||||||
import { BottlingModule } from './bottling/bottling.module';
|
import { BottlingModule } from './bottling/bottling.module';
|
||||||
import { EquipmentModule } from './equipment/equipment.module';
|
import { EquipmentModule } from './equipment/equipment.module';
|
||||||
import { CalendarModule } from './calendar/calendar.module';
|
import { CalendarModule } from './calendar/calendar.module';
|
||||||
|
import { CalendarSyncModule } from './calendar-sync/calendar-sync.module';
|
||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { StatisticsModule } from './statistics/statistics.module';
|
import { StatisticsModule } from './statistics/statistics.module';
|
||||||
import { WebhooksModule } from './webhooks/webhooks.module';
|
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||||
@@ -79,6 +80,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
|||||||
BottlingModule,
|
BottlingModule,
|
||||||
EquipmentModule,
|
EquipmentModule,
|
||||||
CalendarModule,
|
CalendarModule,
|
||||||
|
CalendarSyncModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
StatisticsModule,
|
StatisticsModule,
|
||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
|
|||||||
@@ -271,7 +271,15 @@ export const PERMISSION_CATALOG = [
|
|||||||
2400,
|
2400,
|
||||||
),
|
),
|
||||||
...group('Audit-Log', 'audit', [['read', 'Ansehen']], 2500),
|
...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('Dashboard', 'dashboard', [['read', 'Ansehen']], 2700),
|
||||||
...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800),
|
...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800),
|
||||||
...group('Suche', 'search', [['read', 'Ansehen']], 2900),
|
...group('Suche', 'search', [['read', 'Ansehen']], 2900),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<CalendarSyncSettings> {
|
||||||
|
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<CalendarSyncSettings> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<CalendarSyncSourceType, boolean> = {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.processOutbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processEntry(
|
||||||
|
entry: CalendarSyncOutboxEntry,
|
||||||
|
groupId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string> {
|
||||||
|
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<GraphCalendarEventInput | null> {
|
||||||
|
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<CalendarSyncStatusReport> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, string>; 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { FeedingsService } from './feedings.service';
|
import { FeedingsService } from './feedings.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
|
|
||||||
describe('FeedingsService', () => {
|
describe('FeedingsService', () => {
|
||||||
let service: FeedingsService;
|
let service: FeedingsService;
|
||||||
@@ -29,9 +30,11 @@ describe('FeedingsService', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
|
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||||
service = new FeedingsService(
|
service = new FeedingsService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
calendarSync as unknown as CalendarSyncService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
import { Feeding } from '../../generated/prisma';
|
import { Feeding } from '../../generated/prisma';
|
||||||
import { CreateFeedingDto } from './dto/create-feeding.dto';
|
import { CreateFeedingDto } from './dto/create-feeding.dto';
|
||||||
import { UpdateFeedingDto } from './dto/update-feeding.dto';
|
import { UpdateFeedingDto } from './dto/update-feeding.dto';
|
||||||
@@ -15,6 +16,7 @@ export class FeedingsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly calendarSync: CalendarSyncService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<Feeding[]> {
|
async findAll(): Promise<Feeding[]> {
|
||||||
@@ -59,6 +61,7 @@ export class FeedingsService {
|
|||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: feeding,
|
afterData: feeding,
|
||||||
});
|
});
|
||||||
|
await this.calendarSync.enqueue('FEEDING', feeding.id);
|
||||||
return feeding;
|
return feeding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { HarvestsService } from './harvests.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
|
|
||||||
describe('HarvestsService', () => {
|
describe('HarvestsService', () => {
|
||||||
let service: HarvestsService;
|
let service: HarvestsService;
|
||||||
@@ -37,10 +38,12 @@ describe('HarvestsService', () => {
|
|||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||||
service = new HarvestsService(
|
service = new HarvestsService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
webhooks as unknown as WebhooksService,
|
webhooks as unknown as WebhooksService,
|
||||||
|
calendarSync as unknown as CalendarSyncService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
import { CreateHarvestDto } from './dto/create-harvest.dto';
|
import { CreateHarvestDto } from './dto/create-harvest.dto';
|
||||||
import { UpdateHarvestDto } from './dto/update-harvest.dto';
|
import { UpdateHarvestDto } from './dto/update-harvest.dto';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export class HarvestsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
|
private readonly calendarSync: CalendarSyncService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
@@ -85,6 +87,7 @@ export class HarvestsService {
|
|||||||
});
|
});
|
||||||
const created = await this.findById(harvest.id);
|
const created = await this.findById(harvest.id);
|
||||||
await this.webhooks.dispatch('harvest.created', created);
|
await this.webhooks.dispatch('harvest.created', created);
|
||||||
|
await this.calendarSync.enqueue('HARVEST', harvest.id);
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { AutomationService } from '../automation/automation.service';
|
import { AutomationService } from '../automation/automation.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
|
|
||||||
describe('InspectionsService', () => {
|
describe('InspectionsService', () => {
|
||||||
let service: InspectionsService;
|
let service: InspectionsService;
|
||||||
@@ -39,11 +40,13 @@ describe('InspectionsService', () => {
|
|||||||
evaluateInspection: jest.fn().mockResolvedValue(undefined),
|
evaluateInspection: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||||
service = new InspectionsService(
|
service = new InspectionsService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
webhooks as unknown as WebhooksService,
|
webhooks as unknown as WebhooksService,
|
||||||
automation as unknown as AutomationService,
|
automation as unknown as AutomationService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
calendarSync as unknown as CalendarSyncService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { AutomationService } from '../automation/automation.service';
|
import { AutomationService } from '../automation/automation.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
import { Inspection, Task } from '../../generated/prisma';
|
import { Inspection, Task } from '../../generated/prisma';
|
||||||
import { CreateInspectionDto } from './dto/create-inspection.dto';
|
import { CreateInspectionDto } from './dto/create-inspection.dto';
|
||||||
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
||||||
@@ -15,6 +16,7 @@ export class InspectionsService {
|
|||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
private readonly automation: AutomationService,
|
private readonly automation: AutomationService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly calendarSync: CalendarSyncService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<Inspection[]> {
|
async findAll(): Promise<Inspection[]> {
|
||||||
@@ -47,6 +49,7 @@ export class InspectionsService {
|
|||||||
});
|
});
|
||||||
await this.webhooks.dispatch('inspection.created', inspection);
|
await this.webhooks.dispatch('inspection.created', inspection);
|
||||||
await this.automation.evaluateInspection(inspection);
|
await this.automation.evaluateInspection(inspection);
|
||||||
|
await this.calendarSync.enqueue('INSPECTION', inspection.id);
|
||||||
return inspection;
|
return inspection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { QueensService } from './queens.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
|
|
||||||
describe('QueensService', () => {
|
describe('QueensService', () => {
|
||||||
let service: QueensService;
|
let service: QueensService;
|
||||||
@@ -48,10 +49,12 @@ describe('QueensService', () => {
|
|||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||||
service = new QueensService(
|
service = new QueensService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
webhooks as unknown as WebhooksService,
|
webhooks as unknown as WebhooksService,
|
||||||
|
calendarSync as unknown as CalendarSyncService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@ describe('QueensService', () => {
|
|||||||
currentQueenId: 'queen-old',
|
currentQueenId: 'queen-old',
|
||||||
});
|
});
|
||||||
tx.colony.update.mockResolvedValue({});
|
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');
|
await service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1');
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
import { Queen, QueenStatus } from '../../generated/prisma';
|
import { Queen, QueenStatus } from '../../generated/prisma';
|
||||||
import { CreateQueenDto } from './dto/create-queen.dto';
|
import { CreateQueenDto } from './dto/create-queen.dto';
|
||||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||||
@@ -45,6 +46,7 @@ export class QueensService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
|
private readonly calendarSync: CalendarSyncService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
|
async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
|
||||||
@@ -118,7 +120,7 @@ export class QueensService {
|
|||||||
throw new NotFoundException('Colony not found');
|
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) {
|
if (colony.currentQueenId && colony.currentQueenId !== queen.id) {
|
||||||
await tx.queenColonyHistory.updateMany({
|
await tx.queenColonyHistory.updateMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -137,7 +139,7 @@ export class QueensService {
|
|||||||
where: { id: colony.id },
|
where: { id: colony.id },
|
||||||
data: { currentQueenId: queen.id },
|
data: { currentQueenId: queen.id },
|
||||||
});
|
});
|
||||||
await tx.queenColonyHistory.create({
|
return tx.queenColonyHistory.create({
|
||||||
data: {
|
data: {
|
||||||
queenId: queen.id,
|
queenId: queen.id,
|
||||||
colonyId: colony.id,
|
colonyId: colony.id,
|
||||||
@@ -158,6 +160,7 @@ export class QueensService {
|
|||||||
|
|
||||||
const updated = await this.findById(id);
|
const updated = await this.findById(id);
|
||||||
await this.webhooks.dispatch('queen.replaced', updated);
|
await this.webhooks.dispatch('queen.replaced', updated);
|
||||||
|
await this.calendarSync.enqueue('QUEEN_ASSIGNMENT', history.id);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { TaskTemplatesService } from '../task-templates/task-templates.service';
|
import { TaskTemplatesService } from '../task-templates/task-templates.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
|
|
||||||
describe('TasksService', () => {
|
describe('TasksService', () => {
|
||||||
let service: TasksService;
|
let service: TasksService;
|
||||||
@@ -34,11 +35,13 @@ describe('TasksService', () => {
|
|||||||
taskTemplates = {
|
taskTemplates = {
|
||||||
generateDueTasks: jest.fn().mockResolvedValue(undefined),
|
generateDueTasks: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||||
service = new TasksService(
|
service = new TasksService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
webhooks as unknown as WebhooksService,
|
webhooks as unknown as WebhooksService,
|
||||||
taskTemplates as unknown as TaskTemplatesService,
|
taskTemplates as unknown as TaskTemplatesService,
|
||||||
|
calendarSync as unknown as CalendarSyncService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { TaskTemplatesService } from '../task-templates/task-templates.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 { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
|
||||||
import { CreateTaskDto } from './dto/create-task.dto';
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||||
@@ -19,6 +20,7 @@ export class TasksService {
|
|||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
private readonly taskTemplates: TaskTemplatesService,
|
private readonly taskTemplates: TaskTemplatesService,
|
||||||
|
private readonly calendarSync: CalendarSyncService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: TaskFilter): Promise<Task[]> {
|
async findAll(filter: TaskFilter): Promise<Task[]> {
|
||||||
@@ -53,6 +55,9 @@ export class TasksService {
|
|||||||
afterData: task,
|
afterData: task,
|
||||||
});
|
});
|
||||||
await this.webhooks.dispatch('task.created', task);
|
await this.webhooks.dispatch('task.created', task);
|
||||||
|
if (task.dueDate) {
|
||||||
|
await this.calendarSync.enqueue('TASK', task.id);
|
||||||
|
}
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +82,11 @@ export class TasksService {
|
|||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: task,
|
afterData: task,
|
||||||
});
|
});
|
||||||
|
if (task.dueDate) {
|
||||||
|
await this.calendarSync.enqueue('TASK', task.id);
|
||||||
|
} else {
|
||||||
|
await this.calendarSync.cancel('TASK', task.id);
|
||||||
|
}
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +105,7 @@ export class TasksService {
|
|||||||
afterData: task,
|
afterData: task,
|
||||||
});
|
});
|
||||||
await this.webhooks.dispatch('task.completed', task);
|
await this.webhooks.dispatch('task.completed', task);
|
||||||
|
await this.calendarSync.cancel('TASK', task.id);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
import { TreatmentsService } from './treatments.service';
|
import { TreatmentsService } from './treatments.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
|
|
||||||
describe('TreatmentsService', () => {
|
describe('TreatmentsService', () => {
|
||||||
let service: TreatmentsService;
|
let service: TreatmentsService;
|
||||||
@@ -31,9 +32,11 @@ describe('TreatmentsService', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
|
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||||
service = new TreatmentsService(
|
service = new TreatmentsService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
calendarSync as unknown as CalendarSyncService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||||
import { Treatment } from '../../generated/prisma';
|
import { Treatment } from '../../generated/prisma';
|
||||||
import { CreateTreatmentDto } from './dto/create-treatment.dto';
|
import { CreateTreatmentDto } from './dto/create-treatment.dto';
|
||||||
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
||||||
@@ -10,6 +11,7 @@ export class TreatmentsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly calendarSync: CalendarSyncService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<Treatment[]> {
|
async findAll(): Promise<Treatment[]> {
|
||||||
@@ -51,6 +53,7 @@ export class TreatmentsService {
|
|||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
afterData: treatment,
|
afterData: treatment,
|
||||||
});
|
});
|
||||||
|
await this.calendarSync.enqueue('TREATMENT', treatment.id);
|
||||||
return treatment;
|
return treatment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<Record<SyncTypeKey, boolean>>({
|
||||||
|
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<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
mutation.mutate({ enabled, groupId, ...typeFlags });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>M365-Kalender-Synchronisation</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Ü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.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="sync-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={(checked) => setEnabled(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="sync-enabled">Synchronisation aktiv</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="sync-group-id">Objekt-ID der M365-Gruppe</Label>
|
||||||
|
<Input
|
||||||
|
id="sync-group-id"
|
||||||
|
value={groupId}
|
||||||
|
onChange={(event) => setGroupId(event.target.value)}
|
||||||
|
placeholder="z. B. 3fa85f64-5717-4562-b3fc-2c963f66afa6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Welche Einträge übertragen werden</p>
|
||||||
|
{SYNC_TYPE_FIELDS.map((field) => (
|
||||||
|
<div key={field.key} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`sync-${field.key}`}
|
||||||
|
checked={typeFlags[field.key]}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setTypeFlags((prev) => ({ ...prev, [field.key]: checked === true }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`sync-${field.key}`}>{field.label}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card className="max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4 text-sm">
|
||||||
|
{statusQuery.isLoading ? (
|
||||||
|
<p className="text-muted-foreground">Lädt…</p>
|
||||||
|
) : !status?.configured ? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Nicht konfiguriert - M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET fehlen als
|
||||||
|
Umgebungsvariable.
|
||||||
|
</p>
|
||||||
|
) : !canRunNow ? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Zugangsdaten vorhanden, aber Synchronisation ist deaktiviert oder es ist keine
|
||||||
|
Gruppen-ID gesetzt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
|
||||||
|
<dt className="text-muted-foreground">Status</dt>
|
||||||
|
<dd>
|
||||||
|
<Badge variant="default">Aktiv</Badge>
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted-foreground">Ausstehend</dt>
|
||||||
|
<dd>{status.pendingCount}</dd>
|
||||||
|
<dt className="text-muted-foreground">Fehlgeschlagen</dt>
|
||||||
|
<dd>{status.failedCount}</dd>
|
||||||
|
<dt className="text-muted-foreground">Übertragen</dt>
|
||||||
|
<dd>{status.syncedCount}</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!canRunNow || runNowMutation.isPending}
|
||||||
|
onClick={() => runNowMutation.mutate()}
|
||||||
|
>
|
||||||
|
{runNowMutation.isPending ? "Wird synchronisiert…" : "Jetzt synchronisieren"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarSyncTab() {
|
||||||
|
const {
|
||||||
|
data: settings,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["calendar-sync-settings"],
|
||||||
|
queryFn: getCalendarSyncSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{isError ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
Einstellungen konnten nicht geladen werden.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{settings ? <SettingsForm settings={settings} /> : null}
|
||||||
|
<StatusSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,8 +10,15 @@ import { TaskTemplatesTab } from "./task-templates-tab";
|
|||||||
import { LabelTemplatesTab } from "./label-templates-tab";
|
import { LabelTemplatesTab } from "./label-templates-tab";
|
||||||
import { SocialMediaTab } from "./social-media-tab";
|
import { SocialMediaTab } from "./social-media-tab";
|
||||||
import { AutomationRulesTab } from "./automation-rules-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 }[] = [
|
const TABS: { value: Tab; label: string }[] = [
|
||||||
{ value: "webhooks", label: "Webhooks" },
|
{ value: "webhooks", label: "Webhooks" },
|
||||||
@@ -19,6 +26,7 @@ const TABS: { value: Tab; label: string }[] = [
|
|||||||
{ value: "label-templates", label: "Etikettenvorlagen" },
|
{ value: "label-templates", label: "Etikettenvorlagen" },
|
||||||
{ value: "social-media", label: "Social Media" },
|
{ value: "social-media", label: "Social Media" },
|
||||||
{ value: "rules", label: "Regeln" },
|
{ value: "rules", label: "Regeln" },
|
||||||
|
{ value: "calendar-sync", label: "Kalender-Sync" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isTab(value: string | null): value is Tab {
|
function isTab(value: string | null): value is Tab {
|
||||||
@@ -39,7 +47,7 @@ function AutomationPageContent() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
title="Automatisierung"
|
title="Automatisierung"
|
||||||
description="Webhooks, wiederkehrende Aufgaben, Etikettenvorlagen, Social Media und Regeln"
|
description="Webhooks, wiederkehrende Aufgaben, Etikettenvorlagen, Social Media, Regeln und Kalender-Sync"
|
||||||
gradient="var(--gradient-sky)"
|
gradient="var(--gradient-sky)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -50,6 +58,7 @@ function AutomationPageContent() {
|
|||||||
{tab === "label-templates" ? <LabelTemplatesTab /> : null}
|
{tab === "label-templates" ? <LabelTemplatesTab /> : null}
|
||||||
{tab === "social-media" ? <SocialMediaTab /> : null}
|
{tab === "social-media" ? <SocialMediaTab /> : null}
|
||||||
{tab === "rules" ? <AutomationRulesTab /> : null}
|
{tab === "rules" ? <AutomationRulesTab /> : null}
|
||||||
|
{tab === "calendar-sync" ? <CalendarSyncTab /> : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const SETTINGS_AREA_PERMISSIONS = [
|
|||||||
"import:execute",
|
"import:execute",
|
||||||
"backup:export",
|
"backup:export",
|
||||||
"backup:import",
|
"backup:import",
|
||||||
|
"backup:configure",
|
||||||
|
"calendar:configure",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ const ADMIN_GROUP: NavGroup = {
|
|||||||
"task-templates:read",
|
"task-templates:read",
|
||||||
"label-templates:read",
|
"label-templates:read",
|
||||||
"site-settings:read",
|
"site-settings:read",
|
||||||
|
"calendar:configure",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -19,3 +19,9 @@ stringData:
|
|||||||
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI.
|
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI.
|
||||||
OIDC_CLIENT_ID: "change-me-entra-client-id"
|
OIDC_CLIENT_ID: "change-me-entra-client-id"
|
||||||
OIDC_CLIENT_SECRET: "change-me-entra-client-secret"
|
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"
|
||||||
Reference in new issue
Block a user