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