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.
|
||||
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
|
||||
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
|
||||
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`, eigene, von der SSO-App-Registrierung getrennte Zugangsdaten `M365_CALENDAR_TENANT_ID`/`_CLIENT_ID`/`_CLIENT_SECRET`, ausschließlich Env-Variablen) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
|
||||
|
||||
## 7. Kubernetes
|
||||
|
||||
|
||||
@@ -48,6 +48,20 @@ OIDC_CLIENT_ID="change-me-entra-client-id"
|
||||
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
|
||||
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
||||
|
||||
# M365-Kalender-Synchronisation (Modul Kalender). Eintraege (Aufgaben,
|
||||
# Durchsichten, Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen)
|
||||
# werden einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365-
|
||||
# Gruppe uebertragen. Eigene, separate App-Registrierung in Azure/Entra
|
||||
# (NICHT dieselbe wie OIDC_CLIENT_ID oben) mit Application-Permission
|
||||
# "Calendars.ReadWrite" (Admin-Consent noetig), Client-Credentials-Flow.
|
||||
# Ohne alle drei Werte bleibt die Synchronisation deaktiviert. Zusaetzlich
|
||||
# muss die Synchronisation UND die jeweilige Gruppen-ID in der GUI
|
||||
# (Verwaltung > Automatisierung > Kalender-Sync) aktiviert/gesetzt werden -
|
||||
# diese drei Env-Variablen allein reichen nicht.
|
||||
M365_CALENDAR_TENANT_ID="change-me-m365-tenant-id"
|
||||
M365_CALENDAR_CLIENT_ID="change-me-m365-client-id"
|
||||
M365_CALENDAR_CLIENT_SECRET="change-me-m365-client-secret"
|
||||
|
||||
# Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das
|
||||
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
||||
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose)/)"
|
||||
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarSyncSourceType" AS ENUM ('TASK', 'INSPECTION', 'TREATMENT', 'FEEDING', 'HARVEST', 'QUEEN_ASSIGNMENT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CalendarSyncStatus" AS ENUM ('PENDING', 'SYNCED', 'FAILED', 'SKIPPED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CalendarSyncSettings" (
|
||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"groupId" TEXT,
|
||||
"syncTask" BOOLEAN NOT NULL DEFAULT true,
|
||||
"syncInspection" BOOLEAN NOT NULL DEFAULT true,
|
||||
"syncTreatment" BOOLEAN NOT NULL DEFAULT true,
|
||||
"syncFeeding" BOOLEAN NOT NULL DEFAULT true,
|
||||
"syncHarvest" BOOLEAN NOT NULL DEFAULT true,
|
||||
"syncQueenAssignment" BOOLEAN NOT NULL DEFAULT true,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CalendarSyncSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CalendarSyncOutboxEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sourceType" "CalendarSyncSourceType" NOT NULL,
|
||||
"sourceId" TEXT NOT NULL,
|
||||
"status" "CalendarSyncStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"graphEventId" TEXT,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastError" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CalendarSyncOutboxEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CalendarSyncOutboxEntry_status_idx" ON "CalendarSyncOutboxEntry"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CalendarSyncOutboxEntry_sourceType_sourceId_key" ON "CalendarSyncOutboxEntry"("sourceType", "sourceId");
|
||||
@@ -1064,3 +1064,65 @@ model BackupSettings {
|
||||
retentionCount Int?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kalender-Synchronisation – einseitige Übertragung (ApiCore → M365) von
|
||||
// Kalendereinträgen in den Kalender einer Microsoft-365-Gruppe via Graph API.
|
||||
// Zugangsdaten (Tenant/Client-ID/-Secret) sind bewusst NICHT Teil dieses
|
||||
// Modells - wie bei OIDC_CLIENT_SECRET nur über Env-Variablen (Secret), nie
|
||||
// über die GUI editierbar. groupId ist keine Zugangsdaten-Information und
|
||||
// daher hier GUI-konfigurierbar.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum CalendarSyncSourceType {
|
||||
TASK
|
||||
INSPECTION
|
||||
TREATMENT
|
||||
FEEDING
|
||||
HARVEST
|
||||
QUEEN_ASSIGNMENT
|
||||
}
|
||||
|
||||
enum CalendarSyncStatus {
|
||||
PENDING
|
||||
SYNCED
|
||||
FAILED
|
||||
SKIPPED
|
||||
}
|
||||
|
||||
model CalendarSyncSettings {
|
||||
id String @id @default("singleton")
|
||||
enabled Boolean @default(false)
|
||||
groupId String?
|
||||
syncTask Boolean @default(true)
|
||||
syncInspection Boolean @default(true)
|
||||
syncTreatment Boolean @default(true)
|
||||
syncFeeding Boolean @default(true)
|
||||
syncHarvest Boolean @default(true)
|
||||
syncQueenAssignment Boolean @default(true)
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Outbox statt synchronem Push direkt im Request-Pfad (anders als das
|
||||
// Webhooks-Modul): Graph-API-Aufrufe sind zu langsam/fehleranfällig
|
||||
// (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/
|
||||
// Aufgaben-Erstellung darauf warten zu lassen. Ein periodischer Worker
|
||||
// verarbeitet PENDING-Zeilen und wiederholt fehlgeschlagene Versuche.
|
||||
// @@unique auf [sourceType, sourceId] macht enqueue() ein Upsert: ein
|
||||
// erneutes Enqueue (z. B. nach einer Aufgaben-Änderung) aktualisiert das
|
||||
// bestehende Graph-Event über die gespeicherte graphEventId, statt ein
|
||||
// Duplikat anzulegen.
|
||||
model CalendarSyncOutboxEntry {
|
||||
id String @id @default(cuid())
|
||||
sourceType CalendarSyncSourceType
|
||||
sourceId String
|
||||
status CalendarSyncStatus @default(PENDING)
|
||||
graphEventId String?
|
||||
attempts Int @default(0)
|
||||
lastError String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([sourceType, sourceId])
|
||||
@@index([status])
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { ProductsModule } from './products/products.module';
|
||||
import { BottlingModule } from './bottling/bottling.module';
|
||||
import { EquipmentModule } from './equipment/equipment.module';
|
||||
import { CalendarModule } from './calendar/calendar.module';
|
||||
import { CalendarSyncModule } from './calendar-sync/calendar-sync.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { StatisticsModule } from './statistics/statistics.module';
|
||||
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||
@@ -79,6 +80,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
BottlingModule,
|
||||
EquipmentModule,
|
||||
CalendarModule,
|
||||
CalendarSyncModule,
|
||||
NotificationsModule,
|
||||
StatisticsModule,
|
||||
WebhooksModule,
|
||||
|
||||
@@ -271,7 +271,15 @@ export const PERMISSION_CATALOG = [
|
||||
2400,
|
||||
),
|
||||
...group('Audit-Log', 'audit', [['read', 'Ansehen']], 2500),
|
||||
...group('Kalender', 'calendar', [['read', 'Ansehen']], 2600),
|
||||
...group(
|
||||
'Kalender',
|
||||
'calendar',
|
||||
[
|
||||
['read', 'Ansehen'],
|
||||
['configure', 'M365-Synchronisation konfigurieren'],
|
||||
],
|
||||
2600,
|
||||
),
|
||||
...group('Dashboard', 'dashboard', [['read', 'Ansehen']], 2700),
|
||||
...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800),
|
||||
...group('Suche', 'search', [['read', 'Ansehen']], 2900),
|
||||
|
||||
@@ -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 { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
|
||||
describe('FeedingsService', () => {
|
||||
let service: FeedingsService;
|
||||
@@ -29,9 +30,11 @@ describe('FeedingsService', () => {
|
||||
},
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new FeedingsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { Feeding } from '../../generated/prisma';
|
||||
import { CreateFeedingDto } from './dto/create-feeding.dto';
|
||||
import { UpdateFeedingDto } from './dto/update-feeding.dto';
|
||||
@@ -15,6 +16,7 @@ export class FeedingsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Feeding[]> {
|
||||
@@ -59,6 +61,7 @@ export class FeedingsService {
|
||||
userId: actingUserId,
|
||||
afterData: feeding,
|
||||
});
|
||||
await this.calendarSync.enqueue('FEEDING', feeding.id);
|
||||
return feeding;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HarvestsService } from './harvests.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
|
||||
describe('HarvestsService', () => {
|
||||
let service: HarvestsService;
|
||||
@@ -37,10 +38,12 @@ describe('HarvestsService', () => {
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new HarvestsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { CreateHarvestDto } from './dto/create-harvest.dto';
|
||||
import { UpdateHarvestDto } from './dto/update-harvest.dto';
|
||||
|
||||
@@ -19,6 +20,7 @@ export class HarvestsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
) {}
|
||||
|
||||
async findAll() {
|
||||
@@ -85,6 +87,7 @@ export class HarvestsService {
|
||||
});
|
||||
const created = await this.findById(harvest.id);
|
||||
await this.webhooks.dispatch('harvest.created', created);
|
||||
await this.calendarSync.enqueue('HARVEST', harvest.id);
|
||||
return created;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
|
||||
describe('InspectionsService', () => {
|
||||
let service: InspectionsService;
|
||||
@@ -39,11 +40,13 @@ describe('InspectionsService', () => {
|
||||
evaluateInspection: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new InspectionsService(
|
||||
prisma as unknown as PrismaService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
automation as unknown as AutomationService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { Inspection, Task } from '../../generated/prisma';
|
||||
import { CreateInspectionDto } from './dto/create-inspection.dto';
|
||||
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
||||
@@ -15,6 +16,7 @@ export class InspectionsService {
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly automation: AutomationService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Inspection[]> {
|
||||
@@ -47,6 +49,7 @@ export class InspectionsService {
|
||||
});
|
||||
await this.webhooks.dispatch('inspection.created', inspection);
|
||||
await this.automation.evaluateInspection(inspection);
|
||||
await this.calendarSync.enqueue('INSPECTION', inspection.id);
|
||||
return inspection;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueensService } from './queens.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
|
||||
describe('QueensService', () => {
|
||||
let service: QueensService;
|
||||
@@ -48,10 +49,12 @@ describe('QueensService', () => {
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new QueensService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -77,7 +80,7 @@ describe('QueensService', () => {
|
||||
currentQueenId: 'queen-old',
|
||||
});
|
||||
tx.colony.update.mockResolvedValue({});
|
||||
tx.queenColonyHistory.create.mockResolvedValue({});
|
||||
tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' });
|
||||
|
||||
await service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1');
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { Queen, QueenStatus } from '../../generated/prisma';
|
||||
import { CreateQueenDto } from './dto/create-queen.dto';
|
||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||
@@ -45,6 +46,7 @@ export class QueensService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
|
||||
@@ -118,7 +120,7 @@ export class QueensService {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const history = await this.prisma.$transaction(async (tx) => {
|
||||
if (colony.currentQueenId && colony.currentQueenId !== queen.id) {
|
||||
await tx.queenColonyHistory.updateMany({
|
||||
where: {
|
||||
@@ -137,7 +139,7 @@ export class QueensService {
|
||||
where: { id: colony.id },
|
||||
data: { currentQueenId: queen.id },
|
||||
});
|
||||
await tx.queenColonyHistory.create({
|
||||
return tx.queenColonyHistory.create({
|
||||
data: {
|
||||
queenId: queen.id,
|
||||
colonyId: colony.id,
|
||||
@@ -158,6 +160,7 @@ export class QueensService {
|
||||
|
||||
const updated = await this.findById(id);
|
||||
await this.webhooks.dispatch('queen.replaced', updated);
|
||||
await this.calendarSync.enqueue('QUEEN_ASSIGNMENT', history.id);
|
||||
return updated;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { TaskTemplatesService } from '../task-templates/task-templates.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
|
||||
describe('TasksService', () => {
|
||||
let service: TasksService;
|
||||
@@ -34,11 +35,13 @@ describe('TasksService', () => {
|
||||
taskTemplates = {
|
||||
generateDueTasks: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new TasksService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
taskTemplates as unknown as TaskTemplatesService,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { TaskTemplatesService } from '../task-templates/task-templates.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
@@ -19,6 +20,7 @@ export class TasksService {
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly taskTemplates: TaskTemplatesService,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: TaskFilter): Promise<Task[]> {
|
||||
@@ -53,6 +55,9 @@ export class TasksService {
|
||||
afterData: task,
|
||||
});
|
||||
await this.webhooks.dispatch('task.created', task);
|
||||
if (task.dueDate) {
|
||||
await this.calendarSync.enqueue('TASK', task.id);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -77,6 +82,11 @@ export class TasksService {
|
||||
beforeData: before,
|
||||
afterData: task,
|
||||
});
|
||||
if (task.dueDate) {
|
||||
await this.calendarSync.enqueue('TASK', task.id);
|
||||
} else {
|
||||
await this.calendarSync.cancel('TASK', task.id);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -95,6 +105,7 @@ export class TasksService {
|
||||
afterData: task,
|
||||
});
|
||||
await this.webhooks.dispatch('task.completed', task);
|
||||
await this.calendarSync.cancel('TASK', task.id);
|
||||
return task;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
|
||||
describe('TreatmentsService', () => {
|
||||
let service: TreatmentsService;
|
||||
@@ -31,9 +32,11 @@ describe('TreatmentsService', () => {
|
||||
},
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
||||
service = new TreatmentsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
import { Treatment } from '../../generated/prisma';
|
||||
import { CreateTreatmentDto } from './dto/create-treatment.dto';
|
||||
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
||||
@@ -10,6 +11,7 @@ export class TreatmentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Treatment[]> {
|
||||
@@ -51,6 +53,7 @@ export class TreatmentsService {
|
||||
userId: actingUserId,
|
||||
afterData: treatment,
|
||||
});
|
||||
await this.calendarSync.enqueue('TREATMENT', treatment.id);
|
||||
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 { SocialMediaTab } from "./social-media-tab";
|
||||
import { AutomationRulesTab } from "./automation-rules-tab";
|
||||
import { CalendarSyncTab } from "./calendar-sync-tab";
|
||||
|
||||
type Tab = "webhooks" | "task-templates" | "label-templates" | "social-media" | "rules";
|
||||
type Tab =
|
||||
| "webhooks"
|
||||
| "task-templates"
|
||||
| "label-templates"
|
||||
| "social-media"
|
||||
| "rules"
|
||||
| "calendar-sync";
|
||||
|
||||
const TABS: { value: Tab; label: string }[] = [
|
||||
{ value: "webhooks", label: "Webhooks" },
|
||||
@@ -19,6 +26,7 @@ const TABS: { value: Tab; label: string }[] = [
|
||||
{ value: "label-templates", label: "Etikettenvorlagen" },
|
||||
{ value: "social-media", label: "Social Media" },
|
||||
{ value: "rules", label: "Regeln" },
|
||||
{ value: "calendar-sync", label: "Kalender-Sync" },
|
||||
];
|
||||
|
||||
function isTab(value: string | null): value is Tab {
|
||||
@@ -39,7 +47,7 @@ function AutomationPageContent() {
|
||||
<PageHeader
|
||||
icon={Zap}
|
||||
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)"
|
||||
/>
|
||||
|
||||
@@ -50,6 +58,7 @@ function AutomationPageContent() {
|
||||
{tab === "label-templates" ? <LabelTemplatesTab /> : null}
|
||||
{tab === "social-media" ? <SocialMediaTab /> : null}
|
||||
{tab === "rules" ? <AutomationRulesTab /> : null}
|
||||
{tab === "calendar-sync" ? <CalendarSyncTab /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ const SETTINGS_AREA_PERMISSIONS = [
|
||||
"import:execute",
|
||||
"backup:export",
|
||||
"backup:import",
|
||||
"backup:configure",
|
||||
"calendar:configure",
|
||||
];
|
||||
|
||||
export default function SettingsLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -104,6 +104,7 @@ const ADMIN_GROUP: NavGroup = {
|
||||
"task-templates:read",
|
||||
"label-templates: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.
|
||||
OIDC_CLIENT_ID: "change-me-entra-client-id"
|
||||
OIDC_CLIENT_SECRET: "change-me-entra-client-secret"
|
||||
# Separate App-Registrierung fuer die M365-Kalender-Synchronisation
|
||||
# (Application-Permission "Calendars.ReadWrite", Admin-Consent noetig) -
|
||||
# NICHT dieselbe wie OIDC_CLIENT_ID/-SECRET oben.
|
||||
M365_CALENDAR_TENANT_ID: "change-me-m365-tenant-id"
|
||||
M365_CALENDAR_CLIENT_ID: "change-me-m365-client-id"
|
||||
M365_CALENDAR_CLIENT_SECRET: "change-me-m365-client-secret"
|
||||
Reference in new issue
Block a user