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