import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from "@/lib/auth"; import { PrismaClient } from '@prisma/client'; import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks'; import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo'; import { getOutlookAccessToken } from '@/lib/outlook-token'; const prisma = new PrismaClient(); // GET - Pull changes from Google Tasks into local DB export async function GET(req: NextRequest) { try { const session = await getServerSession(authOptions); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const user = await prisma.user.findUnique({ where: { email: session.user.email } }); if (!user) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } // Find all local tasks linked to external providers const allExternalTasks = await prisma.task.findMany({ where: { userId: user.id, externalProvider: { in: ['google', 'outlook'] }, externalId: { not: null }, deletedAt: null, } }); // Find all synced SomedayLists (for discovering new remote tasks) const syncedLists = await prisma.somedayList.findMany({ where: { userId: user.id, externalId: { not: null }, externalProvider: { not: null }, } }); const googleLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'google'); const outlookLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'outlook'); let updated = 0; let deleted = 0; let created = 0; // --- Google Tasks pull-sync --- const googleSyncedLists = syncedLists.filter(l => l.externalProvider === 'google'); const hasGoogleTasks = googleLocalTasks.length > 0 || googleSyncedLists.length > 0; if (hasGoogleTasks) { const account = await prisma.account.findFirst({ where: { userId: user.id, provider: 'google' } }); if (account?.access_token) { const client = createGoogleClient(account.access_token, account.refresh_token || undefined); // Build set of all Google list IDs to sync (from tasks + synced lists) const googleListIds = new Set(); const listIdToSomedayList = new Map(); for (const task of googleLocalTasks) { if (task.externalListId) googleListIds.add(task.externalListId); } for (const sl of googleSyncedLists) { if (sl.externalId) { googleListIds.add(sl.externalId); listIdToSomedayList.set(sl.externalId, { id: sl.id, title: sl.title }); } } // Group existing local tasks by list 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); } for (const listId of googleListIds) { const localTasks = googleByList.get(listId) || []; try { const remoteTasks = await fetchGoogleTasksForSync(client, listId); const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); // Build externalId -> localId map for parent linking const extToLocalMap = new Map(); for (const t of localTasks) { if (t.externalId) extToLocalMap.set(t.externalId, t.id); } // Build set of existing external IDs for quick lookup const existingExternalIds = new Set(localTasks.map(t => t.externalId)); // Update/delete existing local tasks for (const localTask of localTasks) { 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.updated); const localUpdated = localTask.lastSyncedAt || localTask.updatedAt; if (remoteUpdated <= localUpdated) continue; const updateData: any = { 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; } // Sync parent relationship from Google Tasks if (remote.parent) { const parentLocalId = extToLocalMap.get(remote.parent); if (parentLocalId && localTask.parentTaskId !== parentLocalId) { updateData.parentTaskId = parentLocalId; } } else if (localTask.parentTaskId && !remote.parent) { updateData.parentTaskId = 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() } }); } } // Create new local tasks for remote tasks not yet in local DB const somedayListInfo = listIdToSomedayList.get(listId); if (somedayListInfo) { // Sort: parents first, then children const newRemoteTasks = remoteTasks .filter(rt => !existingExternalIds.has(rt.id)) .sort((a, b) => { if (!a.parent && b.parent) return -1; if (a.parent && !b.parent) return 1; return 0; }); for (const remote of newRemoteTasks) { // Skip empty-title tasks if (!remote.title || !remote.title.trim()) continue; const parentLocalId = remote.parent ? extToLocalMap.get(remote.parent) : undefined; const newTask = await prisma.task.create({ data: { userId: user.id, title: remote.title, description: remote.notes || null, completed: remote.status === 'completed', somedayListId: somedayListInfo.id, externalId: remote.id, externalProvider: 'google', externalListId: listId, parentTaskId: parentLocalId || null, lastSyncedAt: new Date(), } }); extToLocalMap.set(remote.id, newTask.id); existingExternalIds.add(remote.id); created++; } } } catch (listError) { console.error(`Error syncing Google list ${listId}:`, listError); } } } } // --- Microsoft To-Do pull-sync --- const outlookSyncedLists = syncedLists.filter(l => l.externalProvider === 'outlook'); const hasOutlookTasks = outlookLocalTasks.length > 0 || outlookSyncedLists.length > 0; if (hasOutlookTasks) { const outlookToken = await getOutlookAccessToken(user.id); if (outlookToken) { // Build set of all Outlook list IDs to sync const outlookListIds = new Set(); const outlookListIdToSomedayList = new Map(); for (const task of outlookLocalTasks) { if (task.externalListId) outlookListIds.add(task.externalListId); } for (const sl of outlookSyncedLists) { if (sl.externalId) { outlookListIds.add(sl.externalId); outlookListIdToSomedayList.set(sl.externalId, { id: sl.id, title: sl.title }); } } // Group existing local tasks by list 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 of outlookListIds) { const localTasks = outlookByList.get(listId) || []; try { const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId); const remoteMap = new Map(remoteTasks.map(t => [t.id, t])); const existingExternalIds = new Set(localTasks.map(t => t.externalId)); for (const localTask of localTasks) { 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() } }); } } // Create new local tasks for remote tasks not yet in local DB const somedayListInfo = outlookListIdToSomedayList.get(listId); if (somedayListInfo) { const newRemoteTasks = remoteTasks.filter(rt => !existingExternalIds.has(rt.id)); for (const remote of newRemoteTasks) { if (!remote.title || !remote.title.trim()) continue; await prisma.task.create({ data: { userId: user.id, title: remote.title, description: remote.body?.content || null, completed: isMsTodoTaskCompleted(remote.status), somedayListId: somedayListInfo.id, externalId: remote.id, externalProvider: 'outlook', externalListId: listId, lastSyncedAt: new Date(), } }); existingExternalIds.add(remote.id); created++; } } } catch (listError) { console.error(`Error syncing Outlook list ${listId}:`, listError); } } } } return NextResponse.json({ success: true, updated, deleted, created }); } catch (error: unknown) { console.error('Pull sync error:', error); const message = error instanceof Error ? error.message : 'Pull sync failed'; return NextResponse.json({ error: message }, { status: 500 }); } } export async function PATCH(req: NextRequest) { try { const session = await getServerSession(authOptions); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const body = await req.json(); const { taskId, completed, title, action, scheduledDate, notes } = body; if (!taskId) { return NextResponse.json({ error: 'Task ID required' }, { status: 400 }); } const task = await prisma.task.findUnique({ where: { id: taskId }, include: { user: true } }); if (!task) { return NextResponse.json({ error: 'Task not found' }, { status: 404 }); } if (task.user.email !== session.user.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); } // Only sync if external ID is present if (task.externalId && task.externalProvider) { if (task.externalProvider === 'google' && task.externalListId) { const account = await prisma.account.findFirst({ where: { userId: task.userId, provider: 'google' } }); if (account && account.access_token) { const client = createGoogleClient(account.access_token, account.refresh_token || undefined); if (action === 'delete') { await deleteGoogleTask(client, task.externalListId, task.externalId); } else { const updates: { title?: string; notes?: string; status?: 'needsAction' | 'completed'; due?: string | null } = {}; if (title !== undefined) updates.title = title; if (notes !== undefined) updates.notes = notes; if (completed !== undefined) updates.status = completed ? 'completed' : 'needsAction'; if (scheduledDate !== undefined) { updates.due = scheduledDate ? new Date(scheduledDate).toISOString() : null; } if (Object.keys(updates).length > 0) { await updateGoogleTask(client, task.externalListId, task.externalId, updates); } } } } 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 }, data: { lastSyncedAt: new Date() } }); } // Update local task state const updateData: any = {}; if (completed !== undefined) updateData.completed = completed; if (title !== undefined) updateData.title = title; if (scheduledDate !== undefined) updateData.scheduledDate = scheduledDate ? new Date(scheduledDate) : null; if (Object.keys(updateData).length > 0) { const updatedTask = await prisma.task.update({ where: { id: taskId }, data: updateData }); return NextResponse.json({ success: true, task: updatedTask }); } return NextResponse.json({ success: true }); } catch (error: unknown) { console.error('Sync error:', error); const message = error instanceof Error ? error.message : 'Sync failed'; return NextResponse.json({ error: message }, { status: 500 }); } }