My-Weekly-ToDo-List/src/app/api/tasks/sync/route.ts

632 lines
30 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { createGoogleClient, createGoogleTask, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync, GoogleTask } from '@/lib/google-tasks';
import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, createMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
// 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: { in: ['google-calendar', '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<string>();
const listIdToSomedayList = new Map<string, { id: string; title: string }>();
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<string, typeof googleLocalTasks>();
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<string, string>();
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<string>();
const outlookListIdToSomedayList = new Map<string, { id: string; title: string }>();
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<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 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);
}
}
}
}
// --- Synology pull-sync ---
const synologySyncedLists = syncedLists.filter(l => l.externalProvider === 'synology');
const synologyLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'synology');
const hasSynologyTasks = synologyLocalTasks.length > 0 || synologySyncedLists.length > 0;
if (hasSynologyTasks) {
const synoConnection = await prisma.calendarConnection.findFirst({
where: { userId: user.id, provider: 'synology' }
});
if (synoConnection?.accessToken && synoConnection?.refreshToken) {
const [synoUsername, synoPassword] = synoConnection.accessToken.split(':');
const synoServerUrl = synoConnection.refreshToken;
if (synoUsername && synoPassword && synoServerUrl) {
const { fetchSynologyTasks } = await import('@/lib/synology-tasks');
const synoListIds = new Set<string>();
const synoListIdToSomedayList = new Map<string, { id: string; title: string }>();
for (const task of synologyLocalTasks) {
if (task.externalListId) synoListIds.add(task.externalListId);
}
for (const sl of synologySyncedLists) {
if (sl.externalId) {
synoListIds.add(sl.externalId);
synoListIdToSomedayList.set(sl.externalId, { id: sl.id, title: sl.title });
}
}
const synoByList = new Map<string, typeof synologyLocalTasks>();
for (const task of synologyLocalTasks) {
if (!task.externalListId) continue;
if (!synoByList.has(task.externalListId)) synoByList.set(task.externalListId, []);
synoByList.get(task.externalListId)!.push(task);
}
for (const listId of synoListIds) {
const localTasks = synoByList.get(listId) || [];
try {
const remoteTasks = await fetchSynologyTasks(synoServerUrl, synoUsername, synoPassword, listId);
// Remap ID: the fetchSynologyTasks prefixes ids with "synology::"
const remoteMap = new Map(remoteTasks.map(t => [t.id.replace(/^synology::/, ''), t]));
const existingExternalIds = new Set(localTasks.map(t => t.externalId?.replace(/^synology::/, '')));
for (const localTask of localTasks) {
const cleanExtId = localTask.externalId?.replace(/^synology::/, '');
const remote = cleanExtId ? remoteMap.get(cleanExtId) : null;
if (!remote) {
await prisma.task.update({ where: { id: localTask.id }, data: { deletedAt: new Date() } });
deleted++;
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;
await prisma.task.update({ where: { id: localTask.id }, data: updateData });
if (Object.keys(updateData).length > 1) updated++;
}
// Create new local tasks from remote
const somedayListInfo = synoListIdToSomedayList.get(listId);
if (somedayListInfo) {
const newRemoteTasks = remoteTasks.filter(rt => {
const cleanId = rt.id.replace(/^synology::/, '');
return !existingExternalIds.has(cleanId);
});
for (const remote of newRemoteTasks) {
if (!remote.title?.trim()) continue;
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: 'synology',
externalListId: listId,
scheduledDate: remote.due ? new Date(remote.due) : null,
lastSyncedAt: new Date(),
}
});
existingExternalIds.add(remote.id.replace(/^synology::/, ''));
created++;
}
}
} catch (listError) {
console.error(`Error syncing Synology 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: { in: ['google-calendar', '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 });
}
}
// POST - Push a newly created local task to the external provider
export async function POST(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 } = body;
if (!taskId) {
return NextResponse.json({ error: 'Task ID required' }, { status: 400 });
}
const task = await prisma.task.findUnique({
where: { id: taskId },
include: { user: true, somedayList: 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 });
}
// Task must be in a someday list that has an external link
if (!task.somedayList?.externalId || !task.somedayList?.externalProvider) {
return NextResponse.json({ error: 'List is not linked to an external provider' }, { status: 400 });
}
const provider = task.somedayList.externalProvider;
const listExternalId = task.somedayList.externalId;
if (provider === 'outlook') {
const outlookToken = await getOutlookAccessToken(task.userId);
if (!outlookToken) {
return NextResponse.json({ error: 'Outlook token not available' }, { status: 400 });
}
const created = await createMsTodoTask(outlookToken, listExternalId, {
title: task.title,
body: task.description || undefined,
dueDateTime: task.scheduledDate ? task.scheduledDate.toISOString() : undefined,
});
const updatedTask = await prisma.task.update({
where: { id: taskId },
data: {
externalId: created.id,
externalProvider: 'outlook',
externalListId: listExternalId,
lastSyncedAt: new Date(),
}
});
return NextResponse.json({ success: true, task: updatedTask });
}
if (provider === 'google') {
const account = await prisma.account.findFirst({
where: { userId: task.userId, provider: { in: ['google-calendar', 'google'] } }
});
if (!account?.access_token) {
return NextResponse.json({ error: 'Google token not available' }, { status: 400 });
}
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
const created = await createGoogleTask(client, listExternalId, {
title: task.title,
notes: task.description || undefined,
due: task.scheduledDate ? task.scheduledDate.toISOString() : undefined,
});
const updatedTask = await prisma.task.update({
where: { id: taskId },
data: {
externalId: created.id,
externalProvider: 'google',
externalListId: listExternalId,
lastSyncedAt: new Date(),
}
});
return NextResponse.json({ success: true, task: updatedTask });
}
return NextResponse.json({ error: `Provider "${provider}" creation sync not supported yet` }, { status: 400 });
} catch (error: unknown) {
console.error('Task creation sync error:', error);
const message = error instanceof Error ? error.message : 'Task creation sync failed';
return NextResponse.json({ error: message }, { status: 500 });
}
}