My-Weekly-ToDo-List/src/lib/outlook-calendar.ts
mARTin-B78 53de4017e5
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run
fix: @ts-nocheck aus allen drei Dateien entfernen
Die Dateien schalteten die Typpruefung komplett ab und blieben deshalb auch
von ignoreBuildErrors: false unberuehrt -- eine Luecke im gerade erst
geschlossenen Netz.

- calendar-cache.ts brauchte die Ausnahme gar nicht: ohne sie null Fehler.
- microsoft-todo.ts hatte vier Faelle zirkulaerer Typinferenz
  (url -> data -> response -> url), aufgeloest durch explizite Annotationen.
- outlook-calendar.ts verbarg einen moeglichen Laufzeitfehler: Der Code
  streute response!-Assertions, eine Stelle war vergessen. Statt noch einer
  Assertion prueft er jetzt echt, ob nach drei Wiederholversuchen ueberhaupt
  eine Antwort vorliegt.

Ausserdem Block I in TODO.md: Hervorhebung des aktuellen Tages im
Spaltenkopf und die Symbole in Kalenderterminen nach oben rechts, beide mit
Fundstelle in globals.css.

Verifiziert: Typecheck 0 Fehler, npm run build Exit 0.

v1.118.2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:59:57 +02:00

450 lines
16 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
// import { GoogleCalendarEvent as CalendarEvent } from './google-calendar';
export interface OutlookCalendar {
id: string;
name: string;
isDefaultCalendar: boolean;
canEdit: boolean;
color?: string;
hexColor?: string;
owner: {
name: string;
address: string;
};
}
// Microsoft Graph API calendar color names → hex values
const OUTLOOK_COLOR_MAP: Record<string, string> = {
auto: '#0078d4',
lightBlue: '#69afe5',
lightGreen: '#7bd148',
lightOrange: '#ffb878',
lightGray: '#b3b3b3',
lightYellow: '#fbd75b',
lightTeal: '#92e1c0',
lightPink: '#f691b2',
lightBrown: '#c2a282',
lightRed: '#ff887c',
maxColor: '#0078d4',
};
const GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0';
const REDIRECT_URI = process.env.MICROSOFT_REDIRECT_URI || `${process.env.NEXTAUTH_URL}/api/calendar/outlook/callback`;
/**
* Generate OAuth2 Authorization URL
*/
export const getAuthUrl = () => {
const tenant = 'common';
const clientId = process.env.MICROSOFT_CLIENT_ID;
if (!clientId) throw new Error('MICROSOFT_CLIENT_ID is not defined');
const scopes = [
'offline_access',
'user.read',
'Calendars.ReadWrite',
'Tasks.ReadWrite'
].join(' ');
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: REDIRECT_URI,
response_mode: 'query',
scope: scopes,
state: 'outlook-auth' // Can be random for security
});
return `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize?${params.toString()}`;
};
/**
* Exchange Authorization Code for Tokens
*/
export const getTokens = async (code: string) => {
const tenant = 'common';
const clientId = process.env.MICROSOFT_CLIENT_ID;
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
if (!clientId || !clientSecret) throw new Error('Microsoft credentials not defined');
const params = new URLSearchParams({
client_id: clientId,
scope: 'offline_access user.read Calendars.ReadWrite Tasks.ReadWrite',
code: code,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
client_secret: clientSecret
});
const response = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
if (!response.ok) {
const error = await response.text();
console.error('Error getting Outlook tokens:', error);
throw new Error(`Failed to get tokens: ${response.statusText}`);
}
return response.json();
};
/**
* Refresh Access Token
*/
export const refreshAccessToken = async (refreshToken: string) => {
const tenant = 'common';
const clientId = process.env.MICROSOFT_CLIENT_ID;
const clientSecret = process.env.MICROSOFT_CLIENT_SECRET;
if (!clientId || !clientSecret) throw new Error('Microsoft credentials not defined');
const params = new URLSearchParams({
client_id: clientId,
scope: 'offline_access user.read Calendars.ReadWrite Tasks.ReadWrite',
refresh_token: refreshToken,
redirect_uri: REDIRECT_URI,
grant_type: 'refresh_token',
client_secret: clientSecret
});
const response = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params.toString()
});
if (!response.ok) {
const error = await response.text();
console.error('Error refreshing Outlook token:', error);
throw new Error(`Failed to refresh token: ${response.statusText}`);
}
return response.json();
};
/**
* Get User's Calendars
*/
export const getUserCalendars = async (accessToken: string): Promise<OutlookCalendar[]> => {
const response = await fetch(`${GRAPH_ENDPOINT}/me/calendars`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Prefer': 'outlook.timezone="UTC"'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch calendars: ${response.statusText}`);
}
const data = await response.json();
return (data.value || []).map((cal: any) => ({
...cal,
hexColor: cal.hexColor || OUTLOOK_COLOR_MAP[cal.color] || OUTLOOK_COLOR_MAP['auto'],
}));
};
/**
* Get Upcoming Events
*/
export const getUpcomingEvents = async (
accessToken: string,
calendarId: string,
startDateTime: string,
endDateTime: string
) => {
const params = new URLSearchParams({
startDateTime: startDateTime,
endDateTime: endDateTime,
'$select': 'subject,bodyPreview,start,end,location,webLink,isAllDay,seriesMasterId,type,reminderMinutesBeforeStart,isReminderOn,showAs,sensitivity,attendees',
'$orderby': 'start/dateTime',
'$top': '50'
});
let response: Response | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
response = await fetch(
`${GRAPH_ENDPOINT}/me/calendars/${encodeURIComponent(calendarId)}/calendarView?${params.toString()}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Prefer': 'outlook.timezone="UTC"'
}
}
);
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '', 10);
const delay = (retryAfter > 0 ? retryAfter : (attempt + 1) * 2) * 1000;
console.warn(`[OUTLOOK] 429 throttled for calendar ${calendarId}, retrying in ${delay}ms (attempt ${attempt + 1}/3)`);
await new Promise(r => setTimeout(r, delay));
continue;
}
break;
}
// Nach der Retry-Schleife kann response fehlen, wenn jeder Versuch abbrach. Eine echte
// Pruefung statt weiterer "!"-Assertions: die verschieben das Problem nur zur Laufzeit.
if (!response) {
throw new Error(`[OUTLOOK] Keine Antwort fuer Kalender ${calendarId} nach 3 Versuchen`);
}
if (!response.ok) {
const errorText = await response.text();
console.error(`[OUTLOOK] Failed to fetch events for calendar ${calendarId}:`, response.status, response.statusText, errorText);
throw new Error(`Failed to fetch events: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.value.map((event: any) => mapOutlookEventResponse(event));
};
const ensureTimeZone = (dateTimeObj: any) => {
if (!dateTimeObj) return dateTimeObj;
const tz = dateTimeObj.timeZone || 'UTC';
let dt = dateTimeObj.dateTime;
// If dateTime is UTC (ends with Z) but we have a real timezone, convert to local representation
if (dt && typeof dt === 'string' && dt.endsWith('Z') && tz !== 'UTC') {
const d = new Date(dt);
// Format as local time in the target timezone: YYYY-MM-DDTHH:mm:ss.0000000
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).formatToParts(d);
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
dt = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}.0000000`;
}
return { dateTime: dt, timeZone: tz };
};
/**
* Create Outlook Event
*/
// Normalize Outlook response dateTime: ensure UTC dates have Z suffix
const normalizeOutlookDateTime = (dtObj: any) => {
if (!dtObj?.dateTime) return dtObj;
let dt = dtObj.dateTime;
// Outlook returns UTC datetimes without Z suffix — append it
if (dt && typeof dt === 'string' && dtObj.timeZone === 'UTC' && !dt.endsWith('Z')) {
dt = dt + 'Z';
}
return { dateTime: dt, timeZone: dtObj.timeZone };
};
// Map a raw Microsoft Graph event response into our internal shape.
// Used after create/update to keep the full set of fields (busyStatus, visibility,
// attendees, reminders, recurrence info) flowing back to the cache + UI.
const mapOutlookEventResponse = (ev: any) => {
const showAsMap: Record<string, string> = {
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
};
const sensitivityMap: Record<string, string> = {
'normal': 'default', 'personal': 'default',
'private': 'private', 'confidential': 'confidential',
};
return {
id: ev.seriesMasterId ? `${ev.seriesMasterId}::${ev.id}` : ev.id,
summary: ev.subject,
description: ev.body?.content || ev.bodyPreview,
start: normalizeOutlookDateTime(ev.start),
end: normalizeOutlookDateTime(ev.end),
location: ev.location?.displayName,
htmlLink: ev.webLink,
allDay: ev.isAllDay,
recurringEventId: ev.seriesMasterId || undefined,
isRecurring: ev.type === 'occurrence' || ev.type === 'exception' || ev.type === 'seriesMaster',
reminders: ev.isReminderOn && ev.reminderMinutesBeforeStart != null
? [{ method: 'popup', minutes: ev.reminderMinutesBeforeStart }]
: undefined,
busyStatus: showAsMap[ev.showAs] || undefined,
visibility: sensitivityMap[ev.sensitivity] || undefined,
attendees: ev.attendees?.map((a: any) => ({
email: a.emailAddress?.address,
displayName: a.emailAddress?.name,
responseStatus: a.status?.response === 'accepted' ? 'accepted'
: a.status?.response === 'declined' ? 'declined'
: a.status?.response === 'tentativelyAccepted' ? 'tentative'
: 'needsAction',
})),
};
};
export const createEvent = async (
accessToken: string,
calendarId: string,
event: any
) => {
const requestBody = {
subject: event.summary,
body: {
contentType: 'HTML',
content: event.description || ''
},
start: ensureTimeZone(event.start),
end: ensureTimeZone(event.end),
isAllDay: !!event.allDay,
location: {
displayName: event.location || ''
},
...(event.recurrence ? { recurrence: event.recurrence } : {}),
...(event.reminders?.length ? {
isReminderOn: true,
reminderMinutesBeforeStart: event.reminders[0].minutes,
} : {}),
...(event.busyStatus ? { showAs: event.busyStatus === 'oof' ? 'oof' : event.busyStatus } : {}),
...(event.visibility ? {
sensitivity: event.visibility === 'private' ? 'private'
: event.visibility === 'confidential' ? 'confidential'
: 'normal'
} : {}),
...(event.attendees?.length ? {
attendees: event.attendees.map((a: any) => ({
emailAddress: { address: a.email, name: a.displayName || a.email },
type: 'required',
})),
} : {}),
};
console.log('[OUTLOOK] createEvent request body:', JSON.stringify(requestBody, null, 2));
const response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodeURIComponent(calendarId)}/events`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Prefer': 'outlook.timezone="UTC"'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to create Outlook event: ${err}`);
}
const created = await response.json();
return mapOutlookEventResponse(created);
};
/**
* Update Outlook Event
*/
export const updateEvent = async (
accessToken: string,
calendarId: string, // Not strictly needed for Graph ID-based update but kept for interface consistency
eventId: string,
event: any
) => {
const body = JSON.stringify({
subject: event.summary,
body: {
contentType: 'HTML',
content: event.description || ''
},
start: ensureTimeZone(event.start),
end: ensureTimeZone(event.end),
isAllDay: event.allDay !== undefined ? !!event.allDay : undefined,
location: {
displayName: event.location || ''
},
...(event.recurrence ? { recurrence: event.recurrence } : {}),
...(event.reminders?.length ? {
isReminderOn: true,
reminderMinutesBeforeStart: event.reminders[0].minutes,
} : {}),
...(event.busyStatus ? { showAs: event.busyStatus === 'oof' ? 'oof' : event.busyStatus } : {}),
...(event.visibility ? {
sensitivity: event.visibility === 'private' ? 'private'
: event.visibility === 'confidential' ? 'confidential'
: 'normal'
} : {}),
...(event.attendees?.length ? {
attendees: event.attendees.map((a: any) => ({
emailAddress: { address: a.email, name: a.displayName || a.email },
type: 'required',
})),
} : {}),
});
console.log(`[OUTLOOK] updateEvent request body:`, body);
const patchHeaders: Record<string, string> = {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Prefer': 'outlook.timezone="UTC"'
};
const encodedEventId = encodeURIComponent(eventId);
const encodedCalendarId = encodeURIComponent(calendarId);
// Try non-calendar-scoped endpoint first (more reliable for all ID types)
let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${encodedEventId}`, {
method: 'PATCH', headers: patchHeaders, body
});
console.log(`[OUTLOOK] PATCH /me/events/${eventId.slice(-20)} → ${response.status}`);
if (!response.ok && (response.status === 404 || response.status === 400)) {
// Fallback: try calendar-scoped endpoint
response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodedCalendarId}/events/${encodedEventId}`, {
method: 'PATCH', headers: patchHeaders, body
});
console.log(`[OUTLOOK] PATCH fallback /me/calendars/.../events/${eventId.slice(-20)} → ${response.status}`);
}
if (!response.ok) {
const err = await response.text();
console.error(`[OUTLOOK] Update failed:`, err);
throw new Error(`Failed to update Outlook event: ${err}`);
}
const updated = await response.json();
return mapOutlookEventResponse(updated);
};
/**
* Delete Outlook Event
*/
export const deleteEvent = async (
accessToken: string,
calendarId: string,
eventId: string
) => {
const headers = { 'Authorization': `Bearer ${accessToken}` };
// Encode the event ID for URL safety (base64 padding = may need encoding)
const encodedEventId = encodeURIComponent(eventId);
const encodedCalendarId = encodeURIComponent(calendarId);
// Try non-calendar-scoped endpoint first (more reliable for all ID types)
let response = await fetch(`${GRAPH_ENDPOINT}/me/events/${encodedEventId}`, {
method: 'DELETE', headers
});
if (!response.ok && (response.status === 404 || response.status === 400)) {
// Fallback: try calendar-scoped endpoint
response = await fetch(`${GRAPH_ENDPOINT}/me/calendars/${encodedCalendarId}/events/${encodedEventId}`, {
method: 'DELETE', headers
});
}
// 404/410 on delete = already gone, treat as success
if (response.status === 404 || response.status === 410) {
console.log('[OUTLOOK] Event already deleted or not found, treating as success');
return;
}
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to delete Outlook event: ${err}`);
}
};