// @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 = { 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 => { 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 = { 'free': 'free', 'tentative': 'tentative', 'busy': 'busy', 'oof': 'oof', 'workingElsewhere': 'workingElsewhere', }; // Map Outlook sensitivity to our visibility const sensitivityMap: Record = { '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 = { '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}`); } };