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