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

@@ -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,
});
});
});
});