import { Router, Request, Response } from 'express'; import { asyncHandler } from '../utils/asyncHandler'; import { db } from '../db'; import { categories, tasks, subtasks, repeatProfiles, users, friendships, tombstones, type EntityName } from '../db/schema'; import { eq, and, gte, asc, or, inArray, sql } from 'drizzle-orm'; import { authMiddleware } from '../utils/auth'; import { AppError } from '../middleware/errorHandler'; import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation'; const router = Router(); router.use(authMiddleware); router.get('/', asyncHandler(async (req: Request, res: Response) => { const { since } = syncQuerySchema.parse(req.query); const userId = req.user!.userId; const sinceDate = since; // Fetch categories changed since timestamp const changedCategories = await db .select() .from(categories) .where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate))) .orderBy(asc(categories.updatedAt)); // Fetch tasks changed since timestamp const changedTasks = await db .select() .from(tasks) .where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate))) .orderBy(asc(tasks.updatedAt)); // Fetch subtasks changed since timestamp let changedSubtasks: any[] = []; if (changedTasks.length > 0) { changedSubtasks = await db .select() .from(subtasks) .where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate))) .orderBy(asc(subtasks.updatedAt)); } else { // Also fetch subtasks for tasks that might have been deleted changedSubtasks = await db .select() .from(subtasks) .where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate))) .orderBy(asc(subtasks.updatedAt)); } // Fetch repeat profiles changed since timestamp const changedRepeatProfiles = await db .select() .from(repeatProfiles) .where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate))) .orderBy(asc(repeatProfiles.updatedAt)); // Fetch friendships changed since timestamp const changedFriendships = await db .select() .from(friendships) .where(and( or(eq(friendships.userId, userId), eq(friendships.friendId, userId)), gte(friendships.updatedAt, sinceDate) )) .orderBy(asc(friendships.updatedAt)); // Fetch tombstones (deletions) changed since timestamp const changedTombstones = await db .select() .from(tombstones) .where(and(eq(tombstones.userId, userId), gte(tombstones.updatedAt, sinceDate))) .orderBy(asc(tombstones.updatedAt)); const timestamp = Date.now(); res.json({ categories: changedCategories, tasks: changedTasks, subtasks: changedSubtasks, repeatProfiles: changedRepeatProfiles, friendships: changedFriendships, deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })), timestamp, }); })); router.post('/push', asyncHandler(async (req: Request, res: Response) => { const data = pushChangesSchema.parse(req.body); const userId = req.user!.userId; const conflicts: any[] = []; const timestamp = Date.now(); try { await db.transaction(async (tx) => { // Ensure every referenced category exists (FK integrity) so a stale or // never-synced category reference cannot fail the entire push. Missing // categories are recreated as a fallback and the client heals on pull. const referencedCategoryIds = new Set(); for (const task of data.changes.tasks ?? []) { if (task.categoryId) referencedCategoryIds.add(task.categoryId); } for (const sub of data.changes.subtasks ?? []) { const task = (data.changes.tasks ?? []).find((t) => t.id === sub.taskId); if (task?.categoryId) referencedCategoryIds.add(task.categoryId); } if (referencedCategoryIds.size > 0) { const existing = await tx .select({ id: categories.id }) .from(categories) .where(inArray(categories.id, [...referencedCategoryIds])); const existingIds = new Set(existing.map((c) => c.id)); const missing = [...referencedCategoryIds].filter((id) => !existingIds.has(id)); if (missing.length > 0) { const rows = await tx .select({ max: sql`max(${categories.order})` }) .from(categories) .where(eq(categories.userId, userId)); const startOrder = (rows[0]?.max ?? -1) + 1; await tx.insert(categories).values( missing.map((id, i) => ({ id, userId, name: 'Default', color: '#9E9E9E', order: startOrder + i, createdAt: Date.now(), updatedAt: Date.now(), })) ); } } // Sanitize assignee references: only real user ids may be stored (FK). // Stale/unknown assignee ids are silently dropped to null instead of // failing the whole push transaction. const assigneeIds = new Set(); for (const task of data.changes.tasks ?? []) { if (task.assigneeId) assigneeIds.add(task.assigneeId); } for (const sub of data.changes.subtasks ?? []) { if (sub.assigneeId) assigneeIds.add(sub.assigneeId); } const validAssignees = new Set(); if (assigneeIds.size > 0) { const rows = await tx .select({ id: users.id }) .from(users) .where(inArray(users.id, [...assigneeIds])); for (const r of rows) validAssignees.add(r.id); } const sanitizeAssignee = (a: string | null | undefined): string | null => a && validAssignees.has(a) ? a : null; // Process categories if (data.changes.categories && data.changes.categories.length > 0) { for (const cat of data.changes.categories) { const existing = await tx .select() .from(categories) .where(and(eq(categories.id, cat.id), eq(categories.userId, userId))) .limit(1); if (existing.length > 0) { // Check for conflict if (existing[0].updatedAt > cat.updatedAt) { conflicts.push({ entity: 'categories', id: cat.id, serverVersion: existing[0], clientVersion: cat, resolution: 'server_wins', }); continue; // Server wins } await tx .update(categories) .set({ name: cat.name, color: cat.color, order: cat.order, updatedAt: cat.updatedAt, }) .where(and(eq(categories.id, cat.id), eq(categories.userId, userId))); } else { await tx.insert(categories).values({ ...cat, userId, }); } } } // Process tasks if (data.changes.tasks && data.changes.tasks.length > 0) { for (const task of data.changes.tasks) { const existing = await tx .select() .from(tasks) .where(and(eq(tasks.id, task.id), eq(tasks.userId, userId))) .limit(1); if (existing.length > 0) { // Check for conflict if (existing[0].updatedAt > task.updatedAt) { conflicts.push({ entity: 'tasks', id: task.id, serverVersion: existing[0], clientVersion: task, resolution: 'server_wins', }); continue; // Server wins } // Prevent completing tasks with future due dates if (task.completed === true) { const effectiveDueDate = task.dueDate ?? existing[0].dueDate; if (!canCompleteTask(effectiveDueDate)) { conflicts.push({ entity: 'tasks', id: task.id, serverVersion: existing[0], clientVersion: task, resolution: 'server_wins', }); continue; } } await tx .update(tasks) .set({ title: task.title, description: task.description, categoryId: task.categoryId, tags: task.tags ?? '', priority: task.priority, completed: task.completed, dueDate: task.dueDate, dueTime: task.dueTime ?? '', endTime: task.endTime ?? '', allDay: task.allDay ?? false, repeat: task.repeat ?? 'none', repeatInterval: task.repeatInterval ?? 1, repeatDays: task.repeatDays ?? '', seriesId: task.seriesId ?? '', reminder: task.reminder ?? 'none', reminders: task.reminders ?? '', assigneeId: sanitizeAssignee(task.assigneeId), completedAt: task.completedAt ?? null, updatedAt: task.updatedAt, }) .where(and(eq(tasks.id, task.id), eq(tasks.userId, userId))); } else { await tx.insert(tasks).values({ ...task, assigneeId: sanitizeAssignee(task.assigneeId), allDay: task.allDay ?? false, userId, }); } } } // Process subtasks if (data.changes.subtasks && data.changes.subtasks.length > 0) { for (const sub of data.changes.subtasks) { const existing = await tx .select() .from(subtasks) .where(and(eq(subtasks.id, sub.id), eq(subtasks.userId, userId))) .limit(1); if (existing.length > 0) { // Check for conflict if (existing[0].updatedAt > sub.updatedAt) { conflicts.push({ entity: 'subtasks', id: sub.id, serverVersion: existing[0], clientVersion: sub, resolution: 'server_wins', }); continue; // Server wins } await tx .update(subtasks) .set({ taskId: sub.taskId, parentSubtaskId: sub.parentSubtaskId ?? null, categoryId: sub.categoryId ?? null, title: sub.title, description: sub.description ?? '', priority: sub.priority ?? 'none', completed: sub.completed, dueDate: sub.dueDate ?? 0, dueTime: sub.dueTime ?? '', endTime: sub.endTime ?? '', allDay: sub.allDay ?? false, repeat: sub.repeat ?? 'none', repeatInterval: sub.repeatInterval ?? 1, repeatDays: sub.repeatDays ?? '', seriesId: sub.seriesId ?? '', reminder: sub.reminder ?? 'none', reminders: sub.reminders ?? '', assigneeId: sanitizeAssignee(sub.assigneeId), order: sub.order, updatedAt: sub.updatedAt, }) .where(and(eq(subtasks.id, sub.id), eq(subtasks.userId, userId))); } else { await tx.insert(subtasks).values({ ...sub, assigneeId: sanitizeAssignee(sub.assigneeId), userId, }); } } } // Process repeat profiles if (data.changes.repeatProfiles && data.changes.repeatProfiles.length > 0) { for (const profile of data.changes.repeatProfiles) { const existing = await tx .select() .from(repeatProfiles) .where(and(eq(repeatProfiles.id, profile.id), eq(repeatProfiles.userId, userId))) .limit(1); if (existing.length > 0) { // Check for conflict if (existing[0].updatedAt > profile.updatedAt) { conflicts.push({ entity: 'repeatProfiles', id: profile.id, serverVersion: existing[0], clientVersion: profile, resolution: 'server_wins', }); continue; // Server wins } await tx .update(repeatProfiles) .set({ name: profile.name, repeat: profile.repeat, repeatInterval: profile.repeatInterval, repeatDays: profile.repeatDays, updatedAt: profile.updatedAt, }) .where(and(eq(repeatProfiles.id, profile.id), eq(repeatProfiles.userId, userId))); } else { await tx.insert(repeatProfiles).values({ ...profile, userId, }); } } } // Process friendships if (data.changes.friendships && data.changes.friendships.length > 0) { for (const friendship of data.changes.friendships) { const existing = await tx .select() .from(friendships) .where(eq(friendships.id, friendship.id)) .limit(1); if (existing.length > 0) { // Check for conflict if (existing[0].updatedAt > friendship.updatedAt) { conflicts.push({ entity: 'friendships', id: friendship.id, serverVersion: existing[0], clientVersion: friendship, resolution: 'server_wins', }); continue; // Server wins } await tx .update(friendships) .set({ userId: friendship.userId, friendId: friendship.friendId, status: friendship.status, updatedAt: friendship.updatedAt, }) .where(eq(friendships.id, friendship.id)); } else { await tx.insert(friendships).values(friendship); } } } // Process deletions (tombstones) - last so they see the state produced // by the upserts above and resolve by last-writer-wins. if (data.deleted && data.deleted.length > 0) { for (const deleted of data.deleted) { await applyTombstone(tx, deleted.entity, deleted.id, deleted.updatedAt, userId, conflicts); } } }); } catch (error) { console.error('Sync push error:', error); throw new AppError('SERVER_ERROR', 'Failed to process sync push', 500); } res.json({ success: true, timestamp, conflicts, }); })); // Upsert a tombstone row, keeping the latest updatedAt. async function upsertTombstone( tx: any, entity: EntityName, entityId: string, updatedAt: number, userId: string ): Promise { const existing = await tx .select({ updatedAt: tombstones.updatedAt }) .from(tombstones) .where(and(eq(tombstones.entity, entity), eq(tombstones.entityId, entityId))) .limit(1); const merged = Math.max(existing[0]?.updatedAt ?? 0, updatedAt); if (existing.length > 0) { await tx .update(tombstones) .set({ updatedAt: merged }) .where(and(eq(tombstones.entity, entity), eq(tombstones.entityId, entityId))); } else { await tx.insert(tombstones).values({ entity, entityId, userId, updatedAt: merged }); } } // Apply a client deletion. LWW: if the server row is newer than the deletion // timestamp, the deletion is rejected (server_wins conflict) so the client // re-pulls the row. Accepted deletions cascade tombstones to every FK-cascaded // child so all devices remove them too. async function applyTombstone( tx: any, entity: EntityName, id: string, deletedAt: number, userId: string, conflicts: any[] ): Promise { const tombstoneOf = (e: EntityName, ids: string[]) => ids.forEach((i) => upsertTombstone(tx, e, i, deletedAt, userId)); if (entity === 'tasks') { const row = await tx .select() .from(tasks) .where(and(eq(tasks.id, id), eq(tasks.userId, userId))) .limit(1); if (row.length === 0) { await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (row[0].updatedAt > deletedAt) { conflicts.push({ entity: 'tasks', id, serverVersion: row[0], clientVersion: { id, updatedAt: deletedAt }, resolution: 'server_wins', }); return; } const children = await tx .select({ id: subtasks.id }) .from(subtasks) .where(and(eq(subtasks.taskId, id), eq(subtasks.userId, userId))); tombstoneOf('subtasks', children.map((c: any) => c.id)); await tx.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId))); await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (entity === 'categories') { const row = await tx .select() .from(categories) .where(and(eq(categories.id, id), eq(categories.userId, userId))) .limit(1); if (row.length === 0) { await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (row[0].updatedAt > deletedAt) { conflicts.push({ entity: 'categories', id, serverVersion: row[0], clientVersion: { id, updatedAt: deletedAt }, resolution: 'server_wins', }); return; } // Deleting the category cascades its tasks (and their subtasks) - // tombstone all of them so every client removes them. const catTasks = await tx .select() .from(tasks) .where(and(eq(tasks.categoryId, id), eq(tasks.userId, userId))); for (const taskRow of catTasks) { const subIds = await tx .select({ id: subtasks.id }) .from(subtasks) .where(and(eq(subtasks.taskId, taskRow.id), eq(subtasks.userId, userId))); tombstoneOf('subtasks', subIds.map((s: any) => s.id)); tombstoneOf('tasks', [taskRow.id]); } await tx.delete(categories).where(and(eq(categories.id, id), eq(categories.userId, userId))); await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (entity === 'subtasks') { const row = await tx .select() .from(subtasks) .where(and(eq(subtasks.id, id), eq(subtasks.userId, userId))) .limit(1); if (row.length === 0) { await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (row[0].updatedAt > deletedAt) { conflicts.push({ entity: 'subtasks', id, serverVersion: row[0], clientVersion: { id, updatedAt: deletedAt }, resolution: 'server_wins', }); return; } // Deleting a parent subtask cascades its children in PG - tombstone them. const children = await tx .select({ id: subtasks.id }) .from(subtasks) .where(and(eq(subtasks.parentSubtaskId, id), eq(subtasks.userId, userId))); tombstoneOf('subtasks', children.map((c: any) => c.id)); await tx.delete(subtasks).where(and(eq(subtasks.id, id), eq(subtasks.userId, userId))); await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (entity === 'repeatProfiles') { const row = await tx .select() .from(repeatProfiles) .where(and(eq(repeatProfiles.id, id), eq(repeatProfiles.userId, userId))) .limit(1); if (row.length === 0) { await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (row[0].updatedAt > deletedAt) { conflicts.push({ entity: 'repeatProfiles', id, serverVersion: row[0], clientVersion: { id, updatedAt: deletedAt }, resolution: 'server_wins', }); return; } await tx.delete(repeatProfiles).where(and(eq(repeatProfiles.id, id), eq(repeatProfiles.userId, userId))); await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (entity === 'friendships') { const row = await tx .select() .from(friendships) .where(and( eq(friendships.id, id), or(eq(friendships.userId, userId), eq(friendships.friendId, userId)) )) .limit(1); if (row.length === 0) { await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (row[0].updatedAt > deletedAt) { conflicts.push({ entity: 'friendships', id, serverVersion: row[0], clientVersion: { id, updatedAt: deletedAt }, resolution: 'server_wins', }); return; } await tx.delete(friendships).where(eq(friendships.id, id)); await upsertTombstone(tx, entity, id, deletedAt, userId); return; } } export default router;