diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c0d3ca..6061332 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,6 +80,7 @@ model User { tasks Task[] somedayLists SomedayList[] calendarConnections CalendarConnection[] + cachedCalendarEvents CachedCalendarEvent[] weeklyGoals WeeklyGoal[] } @@ -148,7 +149,7 @@ model Task { // External Integration externalId String? - externalProvider String? // "google" | "apple" + externalProvider String? // "google" | "apple" | "outlook" externalListId String? lastSyncedAt DateTime? @@ -183,9 +184,40 @@ model CalendarConnection { calendars Json? // Stores array of { id, title, isPrimary, selected } createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + cachedEvents CachedCalendarEvent[] 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 { id String @id @default(cuid()) userId String diff --git a/src/app/api/calendar/background-sync/route.ts b/src/app/api/calendar/background-sync/route.ts new file mode 100644 index 0000000..7688abd --- /dev/null +++ b/src/app/api/calendar/background-sync/route.ts @@ -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 }); + } +} diff --git a/src/app/api/calendar/connections/route.ts b/src/app/api/calendar/connections/route.ts index e2b553c..20d0ec9 100644 --- a/src/app/api/calendar/connections/route.ts +++ b/src/app/api/calendar/connections/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; -import { PrismaClient } from '@prisma/client'; - -const prisma = new PrismaClient(); +import { prisma } from '@/lib/prisma'; +import { refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache'; // Get user's calendar connections 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 }); } catch (error) { console.error('Error updating calendar connection:', error); diff --git a/src/app/api/calendar/events/route.ts b/src/app/api/calendar/events/route.ts index 8cf5c7d..bad2592 100644 --- a/src/app/api/calendar/events/route.ts +++ b/src/app/api/calendar/events/route.ts @@ -1,10 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-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'; - -const prisma = new PrismaClient(); +import { upsertCachedEvent, deleteCachedEvent } from '@/lib/calendar-cache'; // Helper to find connection by calendarId async function findConnectionForCalendar(userId: string, calendarId: string) { @@ -63,6 +62,10 @@ export async function POST(request: NextRequest) { 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 }); } catch (error: any) { console.error('Error creating event:', error); @@ -100,6 +103,10 @@ export async function PATCH(request: NextRequest) { 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 }); } catch (error: any) { console.error('Error updating event:', error); @@ -132,6 +139,10 @@ export async function DELETE(request: NextRequest) { 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 }); } catch (error: any) { console.error('Error deleting event:', error); diff --git a/src/app/api/calendar/sync/route.ts b/src/app/api/calendar/sync/route.ts index 8171105..c9740a7 100644 --- a/src/app/api/calendar/sync/route.ts +++ b/src/app/api/calendar/sync/route.ts @@ -1,104 +1,78 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; -import { PrismaClient } from '@prisma/client'; -import { getCalendarEvents, CalendarConnection } from '@/lib/calendar-events'; - -const prisma = new PrismaClient(); +import { prisma } from '@/lib/prisma'; +import { readCachedEvents, isCacheStale, refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache'; export async function POST(request: NextRequest) { - console.log('[CALENDAR SYNC] Starting sync request...'); try { const session = await getServerSession(authOptions); - console.log('[CALENDAR SYNC] Session:', session?.user?.email); - if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const body = await request.json(); - const { timeMin, timeMax, connectionId } = body; - console.log('[CALENDAR SYNC] Request params:', { timeMin, timeMax, connectionId }); + const { timeMin, timeMax, connectionId, forceRefresh } = body; + + 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({ where: { email: session.user.email }, - include: { calendarConnections: true } + include: { calendarConnections: true }, }); if (!user) { - console.log('[CALENDAR SYNC] User not found:', session.user.email); 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; - - // If a specific connectionId is provided, filter to just that one if (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) { - console.log('[CALENDAR SYNC] No calendar connections found'); - return NextResponse.json({ - success: true, - events: [], - message: 'No calendar connections found. Please connect a calendar in Settings.' - }); + return NextResponse.json({ success: true, events: [], fromCache: true }); } - // Map to CalendarConnection interface - const calendarConnections: CalendarConnection[] = connections.map(conn => ({ - 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 - })); + // FAST PATH: read from DB cache + const cachedEvents = await readCachedEvents(user.id, timeMinDate, timeMaxDate); - console.log('[CALENDAR SYNC] Fetching events from', calendarConnections.length, 'connections'); - calendarConnections.forEach(c => { - console.log('[CALENDAR SYNC] Connection:', c.provider, 'calendars:', c.calendars?.length || 0); - }); - - // 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() + // Check which connections are stale + const staleChecks = await Promise.all( + connections.map(async conn => ({ + conn, + stale: forceRefresh || await isCacheStale(conn.id, timeMinDate), + })) ); + const staleConnections = staleChecks.filter(c => c.stale).map(c => c.conn); - console.log('[CALENDAR SYNC] Fetched', events.length, 'events'); - - // Transform events to the format expected by the frontend - const formattedEvents = events.map(event => ({ - id: event.id, - title: event.title, - description: event.description, - startTime: event.start.dateTime || event.start.date, - endTime: event.end.dateTime || event.end.date, - source: event.source, - calendarId: event.calendarId, - calendarTitle: event.calendarTitle, - calendarColor: event.backgroundColor - })); - - 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)); + // BACKGROUND REFRESH: fire-and-forget for stale connections + if (staleConnections.length > 0) { + const refreshWork = Promise.allSettled( + staleConnections.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, timeMinDate, timeMaxDate); + }) + ); + refreshWork.catch(e => console.error('[CACHE] Background refresh error:', e)); } return NextResponse.json({ success: true, - events: formattedEvents, - count: formattedEvents.length + events: cachedEvents, + count: cachedEvents.length, + fromCache: true, + staleConnectionCount: staleConnections.length, }); } catch (error) { console.error('[CALENDAR SYNC] Sync request failed:', error); diff --git a/src/app/api/tasks/import/route.ts b/src/app/api/tasks/import/route.ts index 579ff68..a174a81 100644 --- a/src/app/api/tasks/import/route.ts +++ b/src/app/api/tasks/import/route.ts @@ -4,6 +4,8 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { PrismaClient } from '@prisma/client'; 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(); @@ -32,7 +34,7 @@ export async function POST(req: NextRequest) { const body = await req.json(); const { provider, sourceLists } = body; - if (!provider || provider !== 'google') { + if (!provider || !['google', 'outlook'].includes(provider)) { 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 const tasksByList = new Map(); for (const task of importedTasks) { diff --git a/src/app/api/tasks/lists/route.ts b/src/app/api/tasks/lists/route.ts index 6a3c679..9f9d9e3 100644 --- a/src/app/api/tasks/lists/route.ts +++ b/src/app/api/tasks/lists/route.ts @@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { PrismaClient } from '@prisma/client'; import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks'; +import { fetchMsTodoLists } from '@/lib/microsoft-todo'; +import { getOutlookAccessToken } from '@/lib/outlook-token'; const prisma = new PrismaClient(); @@ -16,7 +18,7 @@ export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const provider = searchParams.get('provider'); - if (!provider || provider !== 'google') { + if (!provider || !['google', 'outlook'].includes(provider)) { 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 }); } - const account = await prisma.account.findFirst({ - where: { userId: user.id, provider: 'google' } - }); + if (provider === 'google') { + const account = await prisma.account.findFirst({ + where: { userId: user.id, provider: 'google' } + }); - if (!account || !account.access_token) { - return NextResponse.json({ error: 'Google account not connected' }, { status: 400 }); + if (!account || !account.access_token) { + 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); - const lists = await fetchGoogleTaskLists(client); + if (provider === 'outlook') { + 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) { console.error('Fetch lists error:', error); diff --git a/src/app/api/tasks/sync/route.ts b/src/app/api/tasks/sync/route.ts index 7541d8d..bc84354 100644 --- a/src/app/api/tasks/sync/route.ts +++ b/src/app/api/tasks/sync/route.ts @@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { PrismaClient } from '@prisma/client'; 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(); @@ -21,94 +23,164 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - const account = await prisma.account.findFirst({ - where: { userId: user.id, provider: 'google' } - }); - - 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({ + // Find all local tasks linked to external providers + const allExternalTasks = await prisma.task.findMany({ where: { userId: user.id, - externalProvider: 'google', + externalProvider: { in: ['google', 'outlook'] }, externalId: { not: null }, deletedAt: null, } }); - // Group by externalListId - const tasksByList = new Map(); - for (const task of localTasks) { - if (!task.externalListId) continue; - if (!tasksByList.has(task.externalListId)) { - tasksByList.set(task.externalListId, []); - } - tasksByList.get(task.externalListId)!.push(task); - } + const googleLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'google'); + const outlookLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'outlook'); let updated = 0; let deleted = 0; - for (const [listId, tasks] of tasksByList) { - try { - const remoteTasks = await fetchGoogleTasksForSync(client, listId); - const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); + // --- Google Tasks pull-sync --- + if (googleLocalTasks.length > 0) { + const account = await prisma.account.findFirst({ + where: { userId: user.id, provider: 'google' } + }); - for (const localTask of tasks) { - const remote = remoteMap.get(localTask.externalId!); + if (account?.access_token) { + const client = createGoogleClient(account.access_token, account.refresh_token || undefined); - if (!remote) { - // Task was deleted in Google - soft delete locally - await prisma.task.update({ - where: { id: localTask.id }, - data: { deletedAt: new Date() } - }); - deleted++; - continue; + const googleByList = new Map(); + for (const task of googleLocalTasks) { + if (!task.externalListId) continue; + if (!googleByList.has(task.externalListId)) { + googleByList.set(task.externalListId, []); } + googleByList.get(task.externalListId)!.push(task); + } - // Check if remote is newer - const remoteUpdated = new Date(remote.updated); - const localUpdated = localTask.lastSyncedAt || localTask.updatedAt; - if (remoteUpdated <= localUpdated) continue; + for (const [listId, tasks] of googleByList) { + try { + const remoteTasks = await fetchGoogleTasksForSync(client, listId); + const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); - // Apply remote changes - const updateData: any = { lastSyncedAt: new Date() }; + for (const localTask of tasks) { + const remote = remoteMap.get(localTask.externalId!); - const remoteCompleted = remote.status === 'completed'; - if (remoteCompleted !== localTask.completed) { - updateData.completed = remoteCompleted; - } + if (!remote) { + await prisma.task.update({ + where: { id: localTask.id }, + data: { deletedAt: new Date() } + }); + deleted++; + continue; + } - if (remote.title && remote.title !== localTask.title) { - updateData.title = remote.title; - } + const remoteUpdated = new Date(remote.updated); + const localUpdated = localTask.lastSyncedAt || localTask.updatedAt; + if (remoteUpdated <= localUpdated) continue; - if (remote.notes !== undefined && remote.notes !== (localTask.description || undefined)) { - updateData.description = remote.notes || null; - } + const updateData: any = { lastSyncedAt: new Date() }; - if (Object.keys(updateData).length > 1) { // more than just lastSyncedAt - await prisma.task.update({ - where: { id: localTask.id }, - data: updateData - }); - updated++; - } else { - // Still update lastSyncedAt - await prisma.task.update({ - where: { id: localTask.id }, - data: { lastSyncedAt: new Date() } - }); + const remoteCompleted = remote.status === 'completed'; + if (remoteCompleted !== localTask.completed) { + updateData.completed = remoteCompleted; + } + + if (remote.title && remote.title !== localTask.title) { + updateData.title = remote.title; + } + + if (remote.notes !== undefined && remote.notes !== (localTask.description || undefined)) { + 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(); + 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 (completed !== undefined) updates.status = completed ? 'completed' : 'needsAction'; if (scheduledDate !== undefined) { - // Google Tasks expects RFC 3339 date (YYYY-MM-DDT00:00:00.000Z) updates.due = scheduledDate ? new Date(scheduledDate).toISOString() : null; } 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 await prisma.task.update({ where: { id: taskId }, diff --git a/src/components/ImportListModal.tsx b/src/components/ImportListModal.tsx index 139559c..e180282 100644 --- a/src/components/ImportListModal.tsx +++ b/src/components/ImportListModal.tsx @@ -5,7 +5,7 @@ interface ImportListModalProps { isOpen: boolean; onClose: () => void; onImport: (selectedLists: { id: string, title: string }[]) => void; - provider: 'google' | 'apple' | null; + provider: 'google' | 'apple' | 'outlook' | null; lists: { id: string, title: string }[]; isLoading: boolean; } @@ -72,7 +72,7 @@ export const ImportListModal: React.FC = ({ }}>

- Import from {provider === 'google' ? 'Google Tasks' : 'Apple Reminders'} + Import from {provider === 'google' ? 'Google Tasks' : provider === 'outlook' ? 'Microsoft To-Do' : 'Apple Reminders'}

+
{importStatusMsg && (
{ + 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 { + 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 { + 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 { + await prisma.cachedCalendarEvent.deleteMany({ + where: { userId, externalId, provider }, + }); +} diff --git a/src/lib/calendar-events.ts b/src/lib/calendar-events.ts index eb5df73..3af64c5 100644 --- a/src/lib/calendar-events.ts +++ b/src/lib/calendar-events.ts @@ -364,7 +364,7 @@ export const getCalendarEvents = async ( description: event.description, start: event.start, end: event.end, - location: event.id === 'google' ? event.location : event.location, + location: event.location || '', source: 'outlook' as const, calendarId, calendarTitle: calendarData?.title || 'Outlook Calendar', diff --git a/src/lib/microsoft-todo.ts b/src/lib/microsoft-todo.ts new file mode 100644 index 0000000..aa36bcc --- /dev/null +++ b/src/lib/microsoft-todo.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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'; +}; diff --git a/src/lib/outlook-calendar.ts b/src/lib/outlook-calendar.ts index fa005d8..8b43a30 100644 --- a/src/lib/outlook-calendar.ts +++ b/src/lib/outlook-calendar.ts @@ -28,7 +28,8 @@ export const getAuthUrl = () => { const scopes = [ 'offline_access', 'user.read', - 'Calendars.ReadWrite' + 'Calendars.ReadWrite', + 'Tasks.ReadWrite' ].join(' '); const params = new URLSearchParams({ @@ -55,7 +56,7 @@ export const getTokens = async (code: string) => { const params = new URLSearchParams({ client_id: clientId, - scope: 'offline_access user.read Calendars.ReadWrite', + scope: 'offline_access user.read Calendars.ReadWrite Tasks.ReadWrite', code: code, redirect_uri: REDIRECT_URI, grant_type: 'authorization_code', @@ -91,7 +92,7 @@ export const refreshAccessToken = async (refreshToken: string) => { const params = new URLSearchParams({ client_id: clientId, - scope: 'offline_access user.read Calendars.ReadWrite', + scope: 'offline_access user.read Calendars.ReadWrite Tasks.ReadWrite', refresh_token: refreshToken, redirect_uri: REDIRECT_URI, grant_type: 'refresh_token', diff --git a/src/lib/outlook-token.ts b/src/lib/outlook-token.ts new file mode 100644 index 0000000..f865794 --- /dev/null +++ b/src/lib/outlook-token.ts @@ -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 { + 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; +}