feat: Add Microsoft To-Do and Outlook Calendar integration

Adds full OAuth2 flow, bidirectional task sync, calendar event CRUD,
caching with background refresh, and shared token management for
Microsoft/Outlook services. Extracts common token refresh logic into
shared outlook-token.ts utility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-02-21 22:07:37 +01:00
parent ccb34d12d5
commit db00642c21
15 changed files with 911 additions and 163 deletions

View File

@ -80,6 +80,7 @@ model User {
tasks Task[] tasks Task[]
somedayLists SomedayList[] somedayLists SomedayList[]
calendarConnections CalendarConnection[] calendarConnections CalendarConnection[]
cachedCalendarEvents CachedCalendarEvent[]
weeklyGoals WeeklyGoal[] weeklyGoals WeeklyGoal[]
} }
@ -148,7 +149,7 @@ model Task {
// External Integration // External Integration
externalId String? externalId String?
externalProvider String? // "google" | "apple" externalProvider String? // "google" | "apple" | "outlook"
externalListId String? externalListId String?
lastSyncedAt DateTime? lastSyncedAt DateTime?
@ -183,9 +184,40 @@ model CalendarConnection {
calendars Json? // Stores array of { id, title, isPrimary, selected } calendars Json? // Stores array of { id, title, isPrimary, selected }
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
cachedEvents CachedCalendarEvent[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model CachedCalendarEvent {
id String @id @default(cuid())
userId String
externalId String
connectionId String
provider String // "google" | "apple" | "outlook"
calendarId String
calendarTitle String
calendarColor String?
title String
description String? @db.Text
location String?
startDateTime DateTime?
startDate String? // YYYY-MM-DD for all-day events
endDateTime DateTime?
endDate String? // YYYY-MM-DD for all-day events
weekStart DateTime
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
@@unique([userId, externalId, provider])
@@index([userId, startDateTime])
@@index([userId, startDate])
@@index([connectionId, weekStart])
}
model WeeklyGoal { model WeeklyGoal {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String

View File

@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { isCacheStale, refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const { timeMin, timeMax } = body;
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { calendarConnections: true },
});
if (!user || user.calendarConnections.length === 0) {
return NextResponse.json({ queued: 0 });
}
const now = new Date();
const tMin = timeMin ? new Date(timeMin) : new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const tMax = timeMax ? new Date(timeMax) : new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const staleChecks = await Promise.all(
user.calendarConnections.map(async conn => ({
conn,
stale: await isCacheStale(conn.id, tMin),
}))
);
const toRefresh = staleChecks.filter(c => c.stale).map(c => c.conn);
if (toRefresh.length > 0) {
// Fire-and-forget
Promise.allSettled(
toRefresh.map(conn => {
const rc: RefreshableConnection = {
dbId: conn.id,
userId: user.id,
id: conn.id,
provider: conn.provider as 'google' | 'apple' | 'outlook',
accessToken: conn.accessToken,
refreshToken: conn.refreshToken ?? undefined,
expiresAt: conn.expiresAt ?? undefined,
calendars: conn.calendars as any,
};
return refreshConnectionCache(rc, tMin, tMax);
})
).catch(e => console.error('[BG SYNC]', e));
}
return NextResponse.json({ queued: toRefresh.length });
} catch (error) {
console.error('[BG SYNC] Error:', error);
return NextResponse.json({ queued: 0 });
}
}

View File

@ -1,9 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client'; import { prisma } from '@/lib/prisma';
import { refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
const prisma = new PrismaClient();
// Get user's calendar connections // Get user's calendar connections
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@ -98,6 +97,23 @@ export async function PATCH(request: NextRequest) {
} }
}); });
// Fire-and-forget cache refresh for this connection (covers ±1 week)
const now = new Date();
const tMin = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const tMax = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const rc: RefreshableConnection = {
dbId: updated.id,
userId: user.id,
id: updated.id,
provider: updated.provider as 'google' | 'apple' | 'outlook',
accessToken: updated.accessToken,
refreshToken: updated.refreshToken ?? undefined,
expiresAt: updated.expiresAt ?? undefined,
calendars: calendars,
};
refreshConnectionCache(rc, tMin, tMax)
.catch(e => console.error('[CACHE] Post-selection refresh failed:', e));
return NextResponse.json({ success: true, connection: updated }); return NextResponse.json({ success: true, connection: updated });
} catch (error) { } catch (error) {
console.error('Error updating calendar connection:', error); console.error('Error updating calendar connection:', error);

View File

@ -1,10 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client'; import { prisma } from '@/lib/prisma';
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent, CalendarConnection } from '@/lib/calendar-events'; import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent, CalendarConnection } from '@/lib/calendar-events';
import { upsertCachedEvent, deleteCachedEvent } from '@/lib/calendar-cache';
const prisma = new PrismaClient();
// Helper to find connection by calendarId // Helper to find connection by calendarId
async function findConnectionForCalendar(userId: string, calendarId: string) { async function findConnectionForCalendar(userId: string, calendarId: string) {
@ -63,6 +62,10 @@ export async function POST(request: NextRequest) {
location location
}); });
// Update cache
upsertCachedEvent(userId, connection.id, connection.provider, event)
.catch(e => console.error('[CACHE] Failed to cache created event:', e));
return NextResponse.json({ event }); return NextResponse.json({ event });
} catch (error: any) { } catch (error: any) {
console.error('Error creating event:', error); console.error('Error creating event:', error);
@ -100,6 +103,10 @@ export async function PATCH(request: NextRequest) {
location location
}); });
// Update cache
upsertCachedEvent(userId, connection.id, connection.provider, event)
.catch(e => console.error('[CACHE] Failed to cache updated event:', e));
return NextResponse.json({ event }); return NextResponse.json({ event });
} catch (error: any) { } catch (error: any) {
console.error('Error updating event:', error); console.error('Error updating event:', error);
@ -132,6 +139,10 @@ export async function DELETE(request: NextRequest) {
await deleteCalendarEvent(connection, calendarId, eventId); await deleteCalendarEvent(connection, calendarId, eventId);
// Remove from cache
deleteCachedEvent(userId, eventId, connection.provider)
.catch(e => console.error('[CACHE] Failed to delete cached event:', e));
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error: any) { } catch (error: any) {
console.error('Error deleting event:', error); console.error('Error deleting event:', error);

View File

@ -1,104 +1,78 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client'; import { prisma } from '@/lib/prisma';
import { getCalendarEvents, CalendarConnection } from '@/lib/calendar-events'; import { readCachedEvents, isCacheStale, refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
const prisma = new PrismaClient();
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
console.log('[CALENDAR SYNC] Starting sync request...');
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
console.log('[CALENDAR SYNC] Session:', session?.user?.email);
if (!session?.user?.email) { if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const body = await request.json(); const body = await request.json();
const { timeMin, timeMax, connectionId } = body; const { timeMin, timeMax, connectionId, forceRefresh } = body;
console.log('[CALENDAR SYNC] Request params:', { timeMin, timeMax, connectionId });
const timeMinDate = new Date(timeMin ?? Date.now());
const timeMaxDate = new Date(timeMax ?? Date.now() + 7 * 24 * 60 * 60 * 1000);
// Get the user and their calendar connections
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email }, where: { email: session.user.email },
include: { calendarConnections: true } include: { calendarConnections: true },
}); });
if (!user) { if (!user) {
console.log('[CALENDAR SYNC] User not found:', session.user.email);
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: 'User not found' }, { status: 404 });
} }
console.log('[CALENDAR SYNC] Found user:', user.id, 'with', user.calendarConnections.length, 'connections');
// Get connections to sync
let connections = user.calendarConnections; let connections = user.calendarConnections;
// If a specific connectionId is provided, filter to just that one
if (connectionId) { if (connectionId) {
connections = connections.filter(c => c.id === connectionId); connections = connections.filter(c => c.id === connectionId);
if (connections.length === 0) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 });
}
} }
if (connections.length === 0) { if (connections.length === 0) {
console.log('[CALENDAR SYNC] No calendar connections found'); return NextResponse.json({ success: true, events: [], fromCache: true });
return NextResponse.json({
success: true,
events: [],
message: 'No calendar connections found. Please connect a calendar in Settings.'
});
} }
// Map to CalendarConnection interface // FAST PATH: read from DB cache
const calendarConnections: CalendarConnection[] = connections.map(conn => ({ const cachedEvents = await readCachedEvents(user.id, timeMinDate, timeMaxDate);
id: conn.id,
provider: conn.provider as 'google' | 'apple' | 'outlook',
accessToken: conn.accessToken,
refreshToken: conn.refreshToken || undefined,
expiresAt: conn.expiresAt || undefined,
calendars: conn.calendars as any
}));
console.log('[CALENDAR SYNC] Fetching events from', calendarConnections.length, 'connections'); // Check which connections are stale
calendarConnections.forEach(c => { const staleChecks = await Promise.all(
console.log('[CALENDAR SYNC] Connection:', c.provider, 'calendars:', c.calendars?.length || 0); connections.map(async conn => ({
}); conn,
stale: forceRefresh || await isCacheStale(conn.id, timeMinDate),
// Fetch events from all connected calendars }))
const events = await getCalendarEvents(
calendarConnections,
timeMin || new Date().toISOString(),
timeMax || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
); );
const staleConnections = staleChecks.filter(c => c.stale).map(c => c.conn);
console.log('[CALENDAR SYNC] Fetched', events.length, 'events'); // BACKGROUND REFRESH: fire-and-forget for stale connections
if (staleConnections.length > 0) {
// Transform events to the format expected by the frontend const refreshWork = Promise.allSettled(
const formattedEvents = events.map(event => ({ staleConnections.map(conn => {
id: event.id, const rc: RefreshableConnection = {
title: event.title, dbId: conn.id,
description: event.description, userId: user.id,
startTime: event.start.dateTime || event.start.date, id: conn.id,
endTime: event.end.dateTime || event.end.date, provider: conn.provider as 'google' | 'apple' | 'outlook',
source: event.source, accessToken: conn.accessToken,
calendarId: event.calendarId, refreshToken: conn.refreshToken ?? undefined,
calendarTitle: event.calendarTitle, expiresAt: conn.expiresAt ?? undefined,
calendarColor: event.backgroundColor calendars: conn.calendars as any,
})); };
return refreshConnectionCache(rc, timeMinDate, timeMaxDate);
console.log('[CALENDAR SYNC] Returning', formattedEvents.length, 'formatted events'); })
if (formattedEvents.length > 0) { );
console.log('[CALENDAR SYNC] First 3 events:', JSON.stringify(formattedEvents.slice(0, 3), null, 2)); refreshWork.catch(e => console.error('[CACHE] Background refresh error:', e));
} }
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
events: formattedEvents, events: cachedEvents,
count: formattedEvents.length count: cachedEvents.length,
fromCache: true,
staleConnectionCount: staleConnections.length,
}); });
} catch (error) { } catch (error) {
console.error('[CALENDAR SYNC] Sync request failed:', error); console.error('[CALENDAR SYNC] Sync request failed:', error);

View File

@ -4,6 +4,8 @@ import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks'; import { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks';
import { fetchMsTodoLists, fetchMsTodoTasks, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -32,7 +34,7 @@ export async function POST(req: NextRequest) {
const body = await req.json(); const body = await req.json();
const { provider, sourceLists } = body; const { provider, sourceLists } = body;
if (!provider || provider !== 'google') { if (!provider || !['google', 'outlook'].includes(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
} }
@ -90,6 +92,35 @@ export async function POST(req: NextRequest) {
} }
if (provider === 'outlook') {
const accessToken = await getOutlookAccessToken(user.id);
if (!accessToken) {
return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 });
}
let targetLists = lists;
if (targetLists.length === 0) {
const msTodoLists = await fetchMsTodoLists(accessToken);
if (msTodoLists.length > 0) {
const defaultList = msTodoLists.find(l => l.wellknownListName === 'defaultList') || msTodoLists[0];
targetLists = [{ id: defaultList.id, title: defaultList.displayName }];
}
}
for (const sourceList of targetLists) {
const msTasks = await fetchMsTodoTasks(accessToken, sourceList.id);
importedTasks.push(...msTasks.map(t => ({
title: t.title,
description: t.body?.content || '',
externalId: t.id,
externalListId: sourceList.id,
dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null,
status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted',
sourceListTitle: sourceList.title,
})));
}
}
// Group tasks by source list title // Group tasks by source list title
const tasksByList = new Map<string, ImportedTask[]>(); const tasksByList = new Map<string, ImportedTask[]>();
for (const task of importedTasks) { for (const task of importedTasks) {

View File

@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks'; import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks';
import { fetchMsTodoLists } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -16,7 +18,7 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const provider = searchParams.get('provider'); const provider = searchParams.get('provider');
if (!provider || provider !== 'google') { if (!provider || !['google', 'outlook'].includes(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
} }
@ -28,18 +30,34 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: 'User not found' }, { status: 404 });
} }
const account = await prisma.account.findFirst({ if (provider === 'google') {
where: { userId: user.id, provider: 'google' } const account = await prisma.account.findFirst({
}); where: { userId: user.id, provider: 'google' }
});
if (!account || !account.access_token) { if (!account || !account.access_token) {
return NextResponse.json({ error: 'Google account not connected' }, { status: 400 }); return NextResponse.json({ error: 'Google account not connected' }, { status: 400 });
}
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
const lists = await fetchGoogleTaskLists(client);
return NextResponse.json({ lists: lists.map(l => ({ id: l.id, title: l.title })) });
} }
const client = createGoogleClient(account.access_token, account.refresh_token || undefined); if (provider === 'outlook') {
const lists = await fetchGoogleTaskLists(client); const accessToken = await getOutlookAccessToken(user.id);
if (!accessToken) {
return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 });
}
return NextResponse.json({ lists: lists.map(l => ({ id: l.id, title: l.title })) }); const lists = await fetchMsTodoLists(accessToken);
return NextResponse.json({
lists: lists.map(l => ({ id: l.id, title: l.displayName }))
});
}
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
} catch (error: unknown) { } catch (error: unknown) {
console.error('Fetch lists error:', error); console.error('Fetch lists error:', error);

View File

@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync } from '@/lib/google-tasks'; import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync } from '@/lib/google-tasks';
import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -21,94 +23,164 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: 'User not found' }, { status: 404 });
} }
const account = await prisma.account.findFirst({ // Find all local tasks linked to external providers
where: { userId: user.id, provider: 'google' } const allExternalTasks = await prisma.task.findMany({
});
if (!account?.access_token) {
return NextResponse.json({ error: 'Google account not connected' }, { status: 400 });
}
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
// Find all local tasks linked to Google
const localTasks = await prisma.task.findMany({
where: { where: {
userId: user.id, userId: user.id,
externalProvider: 'google', externalProvider: { in: ['google', 'outlook'] },
externalId: { not: null }, externalId: { not: null },
deletedAt: null, deletedAt: null,
} }
}); });
// Group by externalListId const googleLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'google');
const tasksByList = new Map<string, typeof localTasks>(); const outlookLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'outlook');
for (const task of localTasks) {
if (!task.externalListId) continue;
if (!tasksByList.has(task.externalListId)) {
tasksByList.set(task.externalListId, []);
}
tasksByList.get(task.externalListId)!.push(task);
}
let updated = 0; let updated = 0;
let deleted = 0; let deleted = 0;
for (const [listId, tasks] of tasksByList) { // --- Google Tasks pull-sync ---
try { if (googleLocalTasks.length > 0) {
const remoteTasks = await fetchGoogleTasksForSync(client, listId); const account = await prisma.account.findFirst({
const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); where: { userId: user.id, provider: 'google' }
});
for (const localTask of tasks) { if (account?.access_token) {
const remote = remoteMap.get(localTask.externalId!); const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
if (!remote) { const googleByList = new Map<string, typeof googleLocalTasks>();
// Task was deleted in Google - soft delete locally for (const task of googleLocalTasks) {
await prisma.task.update({ if (!task.externalListId) continue;
where: { id: localTask.id }, if (!googleByList.has(task.externalListId)) {
data: { deletedAt: new Date() } googleByList.set(task.externalListId, []);
});
deleted++;
continue;
} }
googleByList.get(task.externalListId)!.push(task);
}
// Check if remote is newer for (const [listId, tasks] of googleByList) {
const remoteUpdated = new Date(remote.updated); try {
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt; const remoteTasks = await fetchGoogleTasksForSync(client, listId);
if (remoteUpdated <= localUpdated) continue; const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
// Apply remote changes for (const localTask of tasks) {
const updateData: any = { lastSyncedAt: new Date() }; const remote = remoteMap.get(localTask.externalId!);
const remoteCompleted = remote.status === 'completed'; if (!remote) {
if (remoteCompleted !== localTask.completed) { await prisma.task.update({
updateData.completed = remoteCompleted; where: { id: localTask.id },
} data: { deletedAt: new Date() }
});
deleted++;
continue;
}
if (remote.title && remote.title !== localTask.title) { const remoteUpdated = new Date(remote.updated);
updateData.title = remote.title; const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
} if (remoteUpdated <= localUpdated) continue;
if (remote.notes !== undefined && remote.notes !== (localTask.description || undefined)) { const updateData: any = { lastSyncedAt: new Date() };
updateData.description = remote.notes || null;
}
if (Object.keys(updateData).length > 1) { // more than just lastSyncedAt const remoteCompleted = remote.status === 'completed';
await prisma.task.update({ if (remoteCompleted !== localTask.completed) {
where: { id: localTask.id }, updateData.completed = remoteCompleted;
data: updateData }
});
updated++; if (remote.title && remote.title !== localTask.title) {
} else { updateData.title = remote.title;
// Still update lastSyncedAt }
await prisma.task.update({
where: { id: localTask.id }, if (remote.notes !== undefined && remote.notes !== (localTask.description || undefined)) {
data: { lastSyncedAt: new Date() } updateData.description = remote.notes || null;
}); }
if (Object.keys(updateData).length > 1) {
await prisma.task.update({
where: { id: localTask.id },
data: updateData
});
updated++;
} else {
await prisma.task.update({
where: { id: localTask.id },
data: { lastSyncedAt: new Date() }
});
}
}
} catch (listError) {
console.error(`Error syncing Google list ${listId}:`, listError);
}
}
}
}
// --- Microsoft To-Do pull-sync ---
if (outlookLocalTasks.length > 0) {
const outlookToken = await getOutlookAccessToken(user.id);
if (outlookToken) {
const outlookByList = new Map<string, typeof outlookLocalTasks>();
for (const task of outlookLocalTasks) {
if (!task.externalListId) continue;
if (!outlookByList.has(task.externalListId)) {
outlookByList.set(task.externalListId, []);
}
outlookByList.get(task.externalListId)!.push(task);
}
for (const [listId, tasks] of outlookByList) {
try {
const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
for (const localTask of tasks) {
const remote = remoteMap.get(localTask.externalId!);
if (!remote) {
await prisma.task.update({
where: { id: localTask.id },
data: { deletedAt: new Date() }
});
deleted++;
continue;
}
const remoteUpdated = new Date(remote.lastModifiedDateTime);
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
if (remoteUpdated <= localUpdated) continue;
const updateData: any = { lastSyncedAt: new Date() };
const remoteCompleted = isMsTodoTaskCompleted(remote.status);
if (remoteCompleted !== localTask.completed) {
updateData.completed = remoteCompleted;
}
if (remote.title && remote.title !== localTask.title) {
updateData.title = remote.title;
}
const remoteNotes = remote.body?.content || null;
if (remoteNotes !== (localTask.description || null)) {
updateData.description = remoteNotes;
}
if (Object.keys(updateData).length > 1) {
await prisma.task.update({
where: { id: localTask.id },
data: updateData
});
updated++;
} else {
await prisma.task.update({
where: { id: localTask.id },
data: { lastSyncedAt: new Date() }
});
}
}
} catch (listError) {
console.error(`Error syncing Outlook list ${listId}:`, listError);
} }
} }
} catch (listError) {
console.error(`Error syncing list ${listId}:`, listError);
} }
} }
@ -167,7 +239,6 @@ export async function PATCH(req: NextRequest) {
if (notes !== undefined) updates.notes = notes; if (notes !== undefined) updates.notes = notes;
if (completed !== undefined) updates.status = completed ? 'completed' : 'needsAction'; if (completed !== undefined) updates.status = completed ? 'completed' : 'needsAction';
if (scheduledDate !== undefined) { if (scheduledDate !== undefined) {
// Google Tasks expects RFC 3339 date (YYYY-MM-DDT00:00:00.000Z)
updates.due = scheduledDate ? new Date(scheduledDate).toISOString() : null; updates.due = scheduledDate ? new Date(scheduledDate).toISOString() : null;
} }
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
@ -177,6 +248,27 @@ export async function PATCH(req: NextRequest) {
} }
} }
if (task.externalProvider === 'outlook' && task.externalListId) {
const outlookToken = await getOutlookAccessToken(task.userId);
if (outlookToken) {
if (action === 'delete') {
await deleteMsTodoTask(outlookToken, task.externalListId, task.externalId);
} else {
const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null } = {};
if (title !== undefined) updates.title = title;
if (notes !== undefined) updates.body = notes;
if (completed !== undefined) updates.status = completed ? 'completed' : 'notStarted';
if (scheduledDate !== undefined) {
updates.dueDateTime = scheduledDate ? new Date(scheduledDate).toISOString() : null;
}
if (Object.keys(updates).length > 0) {
await updateMsTodoTask(outlookToken, task.externalListId, task.externalId, updates);
}
}
}
}
// Update lastSyncedAt // Update lastSyncedAt
await prisma.task.update({ await prisma.task.update({
where: { id: taskId }, where: { id: taskId },

View File

@ -5,7 +5,7 @@ interface ImportListModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onImport: (selectedLists: { id: string, title: string }[]) => void; onImport: (selectedLists: { id: string, title: string }[]) => void;
provider: 'google' | 'apple' | null; provider: 'google' | 'apple' | 'outlook' | null;
lists: { id: string, title: string }[]; lists: { id: string, title: string }[];
isLoading: boolean; isLoading: boolean;
} }
@ -72,7 +72,7 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
}}> }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}> <h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}>
Import from {provider === 'google' ? 'Google Tasks' : 'Apple Reminders'} Import from {provider === 'google' ? 'Google Tasks' : provider === 'outlook' ? 'Microsoft To-Do' : 'Apple Reminders'}
</h2> </h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full"> <button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full">
<X size={20} /> <X size={20} />

View File

@ -408,7 +408,7 @@ export default function WeeklyView() {
const [importingTasksState, setImportingTasksState] = useState<boolean>(false); const [importingTasksState, setImportingTasksState] = useState<boolean>(false);
const [importStatusMsg, setImportStatusMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null); const [importStatusMsg, setImportStatusMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [importProvider, setImportProvider] = useState<'google' | 'apple' | null>(null); const [importProvider, setImportProvider] = useState<'google' | 'apple' | 'outlook' | null>(null);
const [importLists, setImportLists] = useState<{ id: string, title: string }[]>([]); const [importLists, setImportLists] = useState<{ id: string, title: string }[]>([]);
const [isFetchingLists, setIsFetchingLists] = useState(false); const [isFetchingLists, setIsFetchingLists] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@ -820,6 +820,34 @@ export default function WeeklyView() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [session]); }, [session]);
// Periodic background calendar cache refresh (every 5 minutes)
useEffect(() => {
if (!session) return;
const interval = setInterval(async () => {
try {
const now = new Date();
const res = await fetch('/api/calendar/background-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timeMin: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(),
timeMax: new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString(),
}),
});
if (res.ok) {
const data = await res.json();
if (data.queued > 0) {
// Stale connections are being refreshed; re-fetch events after delay
setTimeout(() => fetchCalendarEvents(), 8000);
}
}
} catch (e) {
// Silent fail for background sync
}
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [session, fetchCalendarEvents]);
async function fetchConnections() { async function fetchConnections() {
try { try {
setIsLoading(true); setIsLoading(true);
@ -1472,7 +1500,7 @@ export default function WeeklyView() {
}; };
const executeImport = async (provider: 'google' | 'apple') => { const executeImport = async (provider: 'google' | 'apple' | 'outlook') => {
setImportProvider(provider); setImportProvider(provider);
setIsImportModalOpen(true); setIsImportModalOpen(true);
setIsFetchingLists(true); setIsFetchingLists(true);
@ -1500,7 +1528,7 @@ export default function WeeklyView() {
}; };
// Core import logic — accepts provider directly so it works both from modal and sidebar // Core import logic — accepts provider directly so it works both from modal and sidebar
const doImport = async (provider: 'google' | 'apple', selectedLists: { id: string, title: string }[]) => { const doImport = async (provider: 'google' | 'apple' | 'outlook', selectedLists: { id: string, title: string }[]) => {
setImportingTasksState(true); setImportingTasksState(true);
setImportStatusMsg(null); setImportStatusMsg(null);
@ -2126,9 +2154,27 @@ export default function WeeklyView() {
const handleSync = async () => { const handleSync = async () => {
setSyncStatus('syncing'); setSyncStatus('syncing');
try { try {
// Pull changes from Google Tasks, then reload everything // Pull changes from Google Tasks, then force-refresh calendar cache
await fetch('/api/tasks/sync').catch(e => console.error('Task pull sync error:', e)); await fetch('/api/tasks/sync').catch(e => console.error('Task pull sync error:', e));
await Promise.all([fetchCalendarEvents(), fetchTasks()]); // Force live refresh from providers (bypass staleness check)
const syncRes = await fetch('/api/calendar/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timeMin: currentWeekStart.toISOString(),
timeMax: new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
forceRefresh: true,
}),
});
if (syncRes.ok) {
const data = await syncRes.json();
if (data.events) setRawCalendarEvents(data.events);
}
await fetchTasks();
// Re-fetch after background refresh completes
if (true) {
setTimeout(() => fetchCalendarEvents(), 8000);
}
setSyncStatus('synced'); setSyncStatus('synced');
setTimeout(() => setSyncStatus('idle'), 3000); setTimeout(() => setSyncStatus('idle'), 3000);
} catch (error) { } catch (error) {
@ -4261,7 +4307,7 @@ interface SettingsSidebarProps {
goalDefaultSentence?: string; goalDefaultSentence?: string;
goalFallbackType?: string; goalFallbackType?: string;
importingTasksState: boolean; importingTasksState: boolean;
executeImport: (provider: 'google' | 'apple') => Promise<void>; executeImport: (provider: 'google' | 'apple' | 'outlook') => Promise<void>;
onImportLists: (lists: { id: string, title: string }[]) => Promise<void>; onImportLists: (lists: { id: string, title: string }[]) => Promise<void>;
importStatusMsg: { type: 'success' | 'error', text: string } | null; importStatusMsg: { type: 'success' | 'error', text: string } | null;
} }
@ -5586,10 +5632,10 @@ function SettingsSidebar({
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600, marginTop: '2rem' }}>Import Tasks</h3> <h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600, marginTop: '2rem' }}>Import Tasks</h3>
<p style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', marginBottom: '1rem' }}> <p style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', marginBottom: '1rem' }}>
Import tasks from Google Tasks into a Someday list. Import tasks from Google Tasks or Microsoft To-Do into a Someday list.
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', gap: '1rem' }}> <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button <button
onClick={() => executeImport('google')} onClick={() => executeImport('google')}
className="calendar-connect-btn" className="calendar-connect-btn"
@ -5598,6 +5644,14 @@ function SettingsSidebar({
> >
<span>📅</span> {importingTasksState ? 'Importing...' : 'Import from Google Tasks'} <span>📅</span> {importingTasksState ? 'Importing...' : 'Import from Google Tasks'}
</button> </button>
<button
onClick={() => executeImport('outlook')}
className="calendar-connect-btn"
disabled={!connections.some(c => c.provider === 'outlook') || importingTasksState}
style={{ opacity: (!connections.some(c => c.provider === 'outlook') || importingTasksState) ? 0.5 : 1 }}
>
<span>📧</span> {importingTasksState ? 'Importing...' : 'Import from Microsoft To-Do'}
</button>
</div> </div>
{importStatusMsg && ( {importStatusMsg && (
<div style={{ <div style={{

204
src/lib/calendar-cache.ts Normal file
View File

@ -0,0 +1,204 @@
// @ts-nocheck
/* eslint-disable @typescript-eslint/no-explicit-any */
import { prisma } from './prisma';
import { getCalendarEvents, CalendarEvent, CalendarConnection } from './calendar-events';
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
/**
* Get the Monday (start of ISO week) for a given date.
*/
function getWeekStart(date: Date): Date {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
const day = d.getUTCDay(); // 0=Sun
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
return d;
}
/**
* Check if the cache for a given connection + time window is stale.
*/
export async function isCacheStale(
connectionId: string,
timeMin: Date,
): Promise<boolean> {
const weekStart = getWeekStart(timeMin);
const newest = await prisma.cachedCalendarEvent.findFirst({
where: { connectionId, weekStart },
orderBy: { syncedAt: 'desc' },
select: { syncedAt: true },
});
if (!newest) return true;
return Date.now() - newest.syncedAt.getTime() > STALE_THRESHOLD_MS;
}
/**
* Read cached events for a user within a date range.
* Returns them in the flat shape the frontend expects.
*/
export async function readCachedEvents(
userId: string,
timeMin: Date,
timeMax: Date,
) {
const minDateStr = timeMin.toISOString().slice(0, 10);
const maxDateStr = timeMax.toISOString().slice(0, 10);
const rows = await prisma.cachedCalendarEvent.findMany({
where: {
userId,
OR: [
// Timed events within range
{
startDateTime: { gte: timeMin, lte: timeMax },
},
// All-day events within range
{
startDate: { gte: minDateStr, lte: maxDateStr },
},
],
},
orderBy: { startDateTime: 'asc' },
});
return rows.map(row => ({
id: row.externalId,
title: row.title,
description: row.description,
startTime: row.startDateTime?.toISOString() ?? row.startDate ?? '',
endTime: row.endDateTime?.toISOString() ?? row.endDate ?? '',
source: row.provider as 'google' | 'apple' | 'outlook',
calendarId: row.calendarId,
calendarTitle: row.calendarTitle,
calendarColor: row.calendarColor,
}));
}
export interface RefreshableConnection extends CalendarConnection {
dbId: string;
userId: string;
}
/**
* Fetch live events from provider, then atomic swap into cache.
*/
export async function refreshConnectionCache(
connection: RefreshableConnection,
timeMin: Date,
timeMax: Date,
): Promise<number> {
const weekStart = getWeekStart(timeMin);
let liveEvents: CalendarEvent[] = [];
try {
liveEvents = await getCalendarEvents(
[connection],
timeMin.toISOString(),
timeMax.toISOString(),
);
} catch (err) {
console.error(`[CACHE] Live fetch failed for connection ${connection.dbId}:`, err);
return 0;
}
const now = new Date();
const rows = liveEvents.map(ev => ({
userId: connection.userId,
externalId: ev.id,
connectionId: connection.dbId,
provider: connection.provider,
calendarId: ev.calendarId,
calendarTitle: ev.calendarTitle,
calendarColor: ev.backgroundColor ?? null,
title: ev.title,
description: ev.description ?? null,
location: ev.location ?? null,
startDateTime: ev.start.dateTime ? new Date(ev.start.dateTime) : null,
startDate: ev.start.date ?? null,
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
endDate: ev.end.date ?? null,
weekStart,
syncedAt: now,
}));
// Atomic swap: delete old events for this connection+week, insert fresh ones
await prisma.$transaction([
prisma.cachedCalendarEvent.deleteMany({
where: { connectionId: connection.dbId, weekStart },
}),
prisma.cachedCalendarEvent.createMany({
data: rows,
skipDuplicates: true,
}),
]);
console.log(`[CACHE] Refreshed ${rows.length} events for ${connection.provider} connection, week ${weekStart.toISOString().slice(0, 10)}`);
return rows.length;
}
/**
* Upsert a single event into cache (after create/update mutations).
*/
export async function upsertCachedEvent(
userId: string,
connectionId: string,
provider: string,
event: CalendarEvent,
): Promise<void> {
const weekStart = getWeekStart(
new Date(event.start.dateTime ?? event.start.date ?? Date.now()),
);
await prisma.cachedCalendarEvent.upsert({
where: {
userId_externalId_provider: { userId, externalId: event.id, provider },
},
create: {
userId,
externalId: event.id,
connectionId,
provider,
calendarId: event.calendarId,
calendarTitle: event.calendarTitle,
calendarColor: event.backgroundColor ?? null,
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
startDateTime: event.start.dateTime ? new Date(event.start.dateTime) : null,
startDate: event.start.date ?? null,
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
endDate: event.end.date ?? null,
weekStart,
syncedAt: new Date(),
},
update: {
calendarTitle: event.calendarTitle,
calendarColor: event.backgroundColor ?? null,
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
startDateTime: event.start.dateTime ? new Date(event.start.dateTime) : null,
startDate: event.start.date ?? null,
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
endDate: event.end.date ?? null,
weekStart,
syncedAt: new Date(),
},
});
}
/**
* Delete a single cached event (after delete mutations).
*/
export async function deleteCachedEvent(
userId: string,
externalId: string,
provider: string,
): Promise<void> {
await prisma.cachedCalendarEvent.deleteMany({
where: { userId, externalId, provider },
});
}

View File

@ -364,7 +364,7 @@ export const getCalendarEvents = async (
description: event.description, description: event.description,
start: event.start, start: event.start,
end: event.end, end: event.end,
location: event.id === 'google' ? event.location : event.location, location: event.location || '',
source: 'outlook' as const, source: 'outlook' as const,
calendarId, calendarId,
calendarTitle: calendarData?.title || 'Outlook Calendar', calendarTitle: calendarData?.title || 'Outlook Calendar',

211
src/lib/microsoft-todo.ts Normal file
View File

@ -0,0 +1,211 @@
// @ts-nocheck
/* eslint-disable @typescript-eslint/no-explicit-any */
const GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0';
export interface MicrosoftTodoList {
id: string;
displayName: string;
isOwner: boolean;
isShared: boolean;
wellknownListName: string;
}
export interface MicrosoftTodoTask {
id: string;
title: string;
body?: {
content: string;
contentType: string;
};
status: 'notStarted' | 'inProgress' | 'completed' | 'waitingOnOthers' | 'deferred';
importance: 'low' | 'normal' | 'high';
dueDateTime?: {
dateTime: string;
timeZone: string;
};
completedDateTime?: {
dateTime: string;
timeZone: string;
};
createdDateTime: string;
lastModifiedDateTime: string;
}
/**
* Fetch all Microsoft To-Do task lists.
*/
export const fetchMsTodoLists = async (accessToken: string): Promise<MicrosoftTodoList[]> => {
const response = await fetch(`${GRAPH_ENDPOINT}/me/todo/lists`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const err = await response.text();
console.error('Error fetching Microsoft To-Do lists:', err);
throw new Error(`Failed to fetch To-Do lists: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return (data.value || []) as MicrosoftTodoList[];
};
/**
* Fetch active tasks from a specific To-Do list (for import).
*/
export const fetchMsTodoTasks = async (
accessToken: string,
listId: string
): Promise<MicrosoftTodoTask[]> => {
const params = new URLSearchParams({
'$filter': "status ne 'completed'",
'$top': '100',
'$select': 'id,title,body,status,importance,dueDateTime,createdDateTime,lastModifiedDateTime'
});
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks?${params.toString()}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
const err = await response.text();
console.error(`Error fetching tasks from list ${listId}:`, err);
throw new Error(`Failed to fetch To-Do tasks: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return (data.value || []) as MicrosoftTodoTask[];
};
/**
* Fetch all tasks (including completed) with optional modified-since filter (for sync).
*/
export const fetchMsTodoTasksForSync = async (
accessToken: string,
listId: string,
modifiedSince?: string
): Promise<MicrosoftTodoTask[]> => {
const selectFields = 'id,title,body,status,dueDateTime,createdDateTime,lastModifiedDateTime';
const params = new URLSearchParams({
'$top': '100',
'$select': selectFields
});
if (modifiedSince) {
params.set('$filter', `lastModifiedDateTime gt ${modifiedSince}`);
}
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks?${params.toString()}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
const err = await response.text();
console.error(`Error fetching sync tasks from list ${listId}:`, err);
throw new Error(`Failed to fetch To-Do tasks for sync: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return (data.value || []) as MicrosoftTodoTask[];
};
/**
* Update a Microsoft To-Do task.
*/
export const updateMsTodoTask = async (
accessToken: string,
listId: string,
taskId: string,
updates: {
title?: string;
body?: string;
status?: 'notStarted' | 'completed';
dueDateTime?: string | null;
}
): Promise<MicrosoftTodoTask> => {
const body: any = {};
if (updates.title !== undefined) body.title = updates.title;
if (updates.body !== undefined) body.body = { content: updates.body, contentType: 'text' };
if (updates.status !== undefined) {
body.status = updates.status;
if (updates.status === 'completed') {
body.completedDateTime = {
dateTime: new Date().toISOString(),
timeZone: 'UTC'
};
} else {
body.completedDateTime = null;
}
}
if (updates.dueDateTime !== undefined) {
body.dueDateTime = updates.dueDateTime
? { dateTime: new Date(updates.dueDateTime).toISOString(), timeZone: 'UTC' }
: null;
}
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}
);
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to update To-Do task: ${err}`);
}
return response.json();
};
/**
* Delete a Microsoft To-Do task.
*/
export const deleteMsTodoTask = async (
accessToken: string,
listId: string,
taskId: string
): Promise<void> => {
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`
}
}
);
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to delete To-Do task: ${err}`);
}
};
/**
* Check if a Microsoft To-Do task status maps to completed.
*/
export const isMsTodoTaskCompleted = (status: MicrosoftTodoTask['status']): boolean => {
return status === 'completed';
};

View File

@ -28,7 +28,8 @@ export const getAuthUrl = () => {
const scopes = [ const scopes = [
'offline_access', 'offline_access',
'user.read', 'user.read',
'Calendars.ReadWrite' 'Calendars.ReadWrite',
'Tasks.ReadWrite'
].join(' '); ].join(' ');
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -55,7 +56,7 @@ export const getTokens = async (code: string) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: clientId, client_id: clientId,
scope: 'offline_access user.read Calendars.ReadWrite', scope: 'offline_access user.read Calendars.ReadWrite Tasks.ReadWrite',
code: code, code: code,
redirect_uri: REDIRECT_URI, redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code', grant_type: 'authorization_code',
@ -91,7 +92,7 @@ export const refreshAccessToken = async (refreshToken: string) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: clientId, client_id: clientId,
scope: 'offline_access user.read Calendars.ReadWrite', scope: 'offline_access user.read Calendars.ReadWrite Tasks.ReadWrite',
refresh_token: refreshToken, refresh_token: refreshToken,
redirect_uri: REDIRECT_URI, redirect_uri: REDIRECT_URI,
grant_type: 'refresh_token', grant_type: 'refresh_token',

41
src/lib/outlook-token.ts Normal file
View File

@ -0,0 +1,41 @@
import { prisma } from '@/lib/prisma';
import { refreshAccessToken } from '@/lib/outlook-calendar';
/**
* Get a valid Outlook access token for a user, refreshing if needed.
* Shared across task sync, import, and list routes.
*/
export async function getOutlookAccessToken(userId: string): Promise<string | null> {
const conn = await prisma.calendarConnection.findFirst({
where: { userId, provider: 'outlook' }
});
if (!conn) return null;
const bufferMs = 5 * 60 * 1000;
const needsRefresh = !conn.expiresAt ||
new Date().getTime() > (new Date(conn.expiresAt).getTime() - bufferMs);
if (needsRefresh && conn.refreshToken) {
try {
const tokenData = await refreshAccessToken(conn.refreshToken);
if (tokenData.access_token) {
const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + tokenData.expires_in);
await prisma.calendarConnection.update({
where: { id: conn.id },
data: {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || conn.refreshToken,
expiresAt
}
});
return tokenData.access_token;
}
} catch (e) {
console.error('Failed to refresh Outlook token:', e);
return null;
}
}
return conn.accessToken;
}