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

+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;
}