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