My-Weekly-ToDo-List/src/lib/outlook-calendar.ts
mARTin 11e7b14c17 fix: add debug logging for Outlook recurring event updates
Log the event ID decomposition, update payload, and Graph API
response status to diagnose why recurring event updates aren't
persisting for Outlook.

v1.74.8

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 14:39:50 +01:00

469 lines
17 KiB
TypeScript

// @ts-nocheck
/* 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;
}
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) => {
// Map Outlook showAs to our busyStatus
const showAsMap: Record<string, string> = {
'free': 'free', 'tentative': 'tentative', 'busy': 'busy',
'oof': 'oof', 'workingElsewhere': 'workingElsewhere',
};
// Map Outlook sensitivity to our visibility
const sensitivityMap: Record<string, string> = {
'normal': 'default', 'personal': 'default', 'private': 'private', 'confidential': 'confidential',
};
// Outlook returns dateTime without Z suffix even when timeZone is UTC.
// Append Z so JS Date parsing treats it as UTC (not local time).
const fixUtc = (dt: string, tz: string) =>
dt && tz === 'UTC' && !dt.endsWith('Z') ? dt + 'Z' : dt;
return {
id: event.seriesMasterId ? `${event.seriesMasterId}::${event.id}` : event.id,
summary: event.subject,
description: event.body?.content || event.bodyPreview,
start: {
dateTime: fixUtc(event.start.dateTime, event.start.timeZone),
timeZone: event.start.timeZone
},
end: {
dateTime: fixUtc(event.end.dateTime, event.end.timeZone),
timeZone: event.end.timeZone
},
location: event.location?.displayName,
htmlLink: event.webLink,
allDay: event.isAllDay,
recurringEventId: event.seriesMasterId || undefined,
isRecurring: event.type === 'occurrence' || event.type === 'exception' || event.type === 'seriesMaster',
reminders: event.isReminderOn && event.reminderMinutesBeforeStart != null
? [{ method: 'popup', minutes: event.reminderMinutesBeforeStart }]
: undefined,
busyStatus: showAsMap[event.showAs] || undefined,
visibility: sensitivityMap[event.sensitivity] || undefined,
attendees: event.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',
})),
};
});
};
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 };
};
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 {
id: created.id,
summary: created.subject,
description: created.bodyPreview,
start: normalizeOutlookDateTime(created.start),
end: normalizeOutlookDateTime(created.end),
location: created.location?.displayName,
allDay: created.isAllDay
};
};
/**
* 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 {
id: updated.id,
summary: updated.subject,
description: updated.bodyPreview,
start: normalizeOutlookDateTime(updated.start),
end: normalizeOutlookDateTime(updated.end),
location: updated.location?.displayName,
allDay: updated.isAllDay
};
};
/**
* 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}`);
}
};