feat: sync calendar entries to a Microsoft 365 group calendar
Backend Image / build-and-push (push) Successful in 8m34s
Frontend Image / build-and-push (push) Successful in 8m41s

Adds a one-way push (ApiCore -> M365) of Aufgaben/Durchsichten/
Behandlungen/Fütterungen/Ernten/Königinnen-Zuordnungen into the calendar
of a Microsoft 365 group, via the Graph API.

- CalendarSyncModule (app-only client-credentials Graph auth, separate
  from the interactive SSO app registration; new env vars
  M365_CALENDAR_TENANT_ID/_CLIENT_ID/_CLIENT_SECRET).
- Outbox + minute-interval cron worker (CalendarSyncOutboxEntry) instead
  of a synchronous push, since Graph calls are too slow/flaky to block a
  domain create request on; failed syncs retry up to 5 times.
- Per-source-type toggle plus a group-ID field, configurable in
  Verwaltung > Automatisierung > Kalender-Sync (Verwaltung > Backup, an
  env var still wins on those fields where one applies to related config).
- Hooked into Tasks/Inspections/Treatments/Feedings/Harvests/Queens at
  their existing create (and Task update/complete) points; entries render
  as all-day Graph events to avoid timezone ambiguity.
- Documents the design decision in ARCHITECTURE.md (Annahme 9), since
  this is a net-new capability the lastenheft doesn't scope.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 09:23:35 +02:00
1 parent 28ae0a2723
commit e7b157ec35
35 files changed
+1760 -7

No files matched your search

+14
View File
@@ -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.
+1 -1
View File
@@ -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");
+62
View File
@@ -1064,3 +1064,65 @@ model BackupSettings {
retentionCount Int?
updatedAt DateTime @updatedAt
}
// ---------------------------------------------------------------------------
// Kalender-Synchronisation – einseitige Übertragung (ApiCore → M365) von
// Kalendereinträgen in den Kalender einer Microsoft-365-Gruppe via Graph API.
// Zugangsdaten (Tenant/Client-ID/-Secret) sind bewusst NICHT Teil dieses
// Modells - wie bei OIDC_CLIENT_SECRET nur über Env-Variablen (Secret), nie
// über die GUI editierbar. groupId ist keine Zugangsdaten-Information und
// daher hier GUI-konfigurierbar.
// ---------------------------------------------------------------------------
enum CalendarSyncSourceType {
TASK
INSPECTION
TREATMENT
FEEDING
HARVEST
QUEEN_ASSIGNMENT
}
enum CalendarSyncStatus {
PENDING
SYNCED
FAILED
SKIPPED
}
model CalendarSyncSettings {
id String @id @default("singleton")
enabled Boolean @default(false)
groupId String?
syncTask Boolean @default(true)
syncInspection Boolean @default(true)
syncTreatment Boolean @default(true)
syncFeeding Boolean @default(true)
syncHarvest Boolean @default(true)
syncQueenAssignment Boolean @default(true)
updatedAt DateTime @updatedAt
}
// Outbox statt synchronem Push direkt im Request-Pfad (anders als das
// Webhooks-Modul): Graph-API-Aufrufe sind zu langsam/fehleranfällig
// (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/
// Aufgaben-Erstellung darauf warten zu lassen. Ein periodischer Worker
// verarbeitet PENDING-Zeilen und wiederholt fehlgeschlagene Versuche.
// @@unique auf [sourceType, sourceId] macht enqueue() ein Upsert: ein
// erneutes Enqueue (z. B. nach einer Aufgaben-Änderung) aktualisiert das
// bestehende Graph-Event über die gespeicherte graphEventId, statt ein
// Duplikat anzulegen.
model CalendarSyncOutboxEntry {
id String @id @default(cuid())
sourceType CalendarSyncSourceType
sourceId String
status CalendarSyncStatus @default(PENDING)
graphEventId String?
attempts Int @default(0)
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([sourceType, sourceId])
@@index([status])
}
+2
View File
@@ -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');
+5 -2
View File
@@ -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;
}