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>
469 lines
17 KiB
TypeScript
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}`);
|
|
}
|
|
};
|