"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = require("express"); const asyncHandler_1 = require("../utils/asyncHandler"); const db_1 = require("../db"); const schema_1 = require("../db/schema"); const drizzle_orm_1 = require("drizzle-orm"); const auth_1 = require("../utils/auth"); const errorHandler_1 = require("../middleware/errorHandler"); const validation_1 = require("../utils/validation"); const router = (0, express_1.Router)(); router.use(auth_1.authMiddleware); router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => { const { since } = validation_1.syncQuerySchema.parse(req.query); const userId = req.user.userId; const sinceDate = since; // Fetch categories changed since timestamp const changedCategories = await db_1.db .select() .from(schema_1.categories) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.userId, userId), (0, drizzle_orm_1.gte)(schema_1.categories.updatedAt, sinceDate))) .orderBy((0, drizzle_orm_1.asc)(schema_1.categories.updatedAt)); // Fetch tasks changed since timestamp const changedTasks = await db_1.db .select() .from(schema_1.tasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tasks.updatedAt, sinceDate))) .orderBy((0, drizzle_orm_1.asc)(schema_1.tasks.updatedAt)); // Fetch subtasks changed since timestamp let changedSubtasks = []; if (changedTasks.length > 0) { changedSubtasks = await db_1.db .select() .from(schema_1.subtasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.subtasks.updatedAt, sinceDate))) .orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt)); } else { // Also fetch subtasks for tasks that might have been deleted changedSubtasks = await db_1.db .select() .from(schema_1.subtasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.subtasks.updatedAt, sinceDate))) .orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt)); } // Fetch repeat profiles changed since timestamp const changedRepeatProfiles = await db_1.db .select() .from(schema_1.repeatProfiles) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId), (0, drizzle_orm_1.gte)(schema_1.repeatProfiles.updatedAt, sinceDate))) .orderBy((0, drizzle_orm_1.asc)(schema_1.repeatProfiles.updatedAt)); // Fetch friendships changed since timestamp const changedFriendships = await db_1.db .select() .from(schema_1.friendships) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId)), (0, drizzle_orm_1.gte)(schema_1.friendships.updatedAt, sinceDate))) .orderBy((0, drizzle_orm_1.asc)(schema_1.friendships.updatedAt)); // Fetch tombstones (deletions) changed since timestamp const changedTombstones = await db_1.db .select() .from(schema_1.tombstones) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tombstones.updatedAt, sinceDate))) .orderBy((0, drizzle_orm_1.asc)(schema_1.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', (0, asyncHandler_1.asyncHandler)(async (req, res) => { const data = validation_1.pushChangesSchema.parse(req.body); const userId = req.user.userId; const conflicts = []; const timestamp = Date.now(); try { await db_1.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: schema_1.categories.id }) .from(schema_1.categories) .where((0, drizzle_orm_1.inArray)(schema_1.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: (0, drizzle_orm_1.sql) `max(${schema_1.categories.order})` }) .from(schema_1.categories) .where((0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)); const startOrder = (rows[0]?.max ?? -1) + 1; await tx.insert(schema_1.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: schema_1.users.id }) .from(schema_1.users) .where((0, drizzle_orm_1.inArray)(schema_1.users.id, [...assigneeIds])); for (const r of rows) validAssignees.add(r.id); } const sanitizeAssignee = (a) => 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(schema_1.categories) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, cat.id), (0, drizzle_orm_1.eq)(schema_1.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(schema_1.categories) .set({ name: cat.name, color: cat.color, order: cat.order, updatedAt: cat.updatedAt, }) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, cat.id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId))); } else { await tx.insert(schema_1.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(schema_1.tasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.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 (!(0, validation_1.canCompleteTask)(effectiveDueDate)) { conflicts.push({ entity: 'tasks', id: task.id, serverVersion: existing[0], clientVersion: task, resolution: 'server_wins', }); continue; } } await tx .update(schema_1.tasks) .set({ title: task.title, description: task.description, categoryId: task.categoryId, 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((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId))); } else { await tx.insert(schema_1.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(schema_1.subtasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, sub.id), (0, drizzle_orm_1.eq)(schema_1.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(schema_1.subtasks) .set({ taskId: sub.taskId, parentSubtaskId: sub.parentSubtaskId ?? 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((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, sub.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId))); } else { await tx.insert(schema_1.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(schema_1.repeatProfiles) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, profile.id), (0, drizzle_orm_1.eq)(schema_1.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(schema_1.repeatProfiles) .set({ name: profile.name, repeat: profile.repeat, repeatInterval: profile.repeatInterval, repeatDays: profile.repeatDays, updatedAt: profile.updatedAt, }) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, profile.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId))); } else { await tx.insert(schema_1.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(schema_1.friendships) .where((0, drizzle_orm_1.eq)(schema_1.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(schema_1.friendships) .set({ userId: friendship.userId, friendId: friendship.friendId, status: friendship.status, updatedAt: friendship.updatedAt, }) .where((0, drizzle_orm_1.eq)(schema_1.friendships.id, friendship.id)); } else { await tx.insert(schema_1.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 errorHandler_1.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, entity, entityId, updatedAt, userId) { const existing = await tx .select({ updatedAt: schema_1.tombstones.updatedAt }) .from(schema_1.tombstones) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.entity, entity), (0, drizzle_orm_1.eq)(schema_1.tombstones.entityId, entityId))) .limit(1); const merged = Math.max(existing[0]?.updatedAt ?? 0, updatedAt); if (existing.length > 0) { await tx .update(schema_1.tombstones) .set({ updatedAt: merged }) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.entity, entity), (0, drizzle_orm_1.eq)(schema_1.tombstones.entityId, entityId))); } else { await tx.insert(schema_1.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, entity, id, deletedAt, userId, conflicts) { const tombstoneOf = (e, ids) => ids.forEach((i) => upsertTombstone(tx, e, i, deletedAt, userId)); if (entity === 'tasks') { const row = await tx .select() .from(schema_1.tasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, id), (0, drizzle_orm_1.eq)(schema_1.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: schema_1.subtasks.id }) .from(schema_1.subtasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId))); tombstoneOf('subtasks', children.map((c) => c.id)); await tx.delete(schema_1.tasks).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId))); await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (entity === 'categories') { const row = await tx .select() .from(schema_1.categories) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, id), (0, drizzle_orm_1.eq)(schema_1.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(schema_1.tasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.categoryId, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId))); for (const taskRow of catTasks) { const subIds = await tx .select({ id: schema_1.subtasks.id }) .from(schema_1.subtasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, taskRow.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId))); tombstoneOf('subtasks', subIds.map((s) => s.id)); tombstoneOf('tasks', [taskRow.id]); } await tx.delete(schema_1.categories).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId))); await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (entity === 'subtasks') { const row = await tx .select() .from(schema_1.subtasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, id), (0, drizzle_orm_1.eq)(schema_1.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: schema_1.subtasks.id }) .from(schema_1.subtasks) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.parentSubtaskId, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId))); tombstoneOf('subtasks', children.map((c) => c.id)); await tx.delete(schema_1.subtasks).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId))); await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (entity === 'repeatProfiles') { const row = await tx .select() .from(schema_1.repeatProfiles) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, id), (0, drizzle_orm_1.eq)(schema_1.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(schema_1.repeatProfiles).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId))); await upsertTombstone(tx, entity, id, deletedAt, userId); return; } if (entity === 'friendships') { const row = await tx .select() .from(schema_1.friendships) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.id, id), (0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.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(schema_1.friendships).where((0, drizzle_orm_1.eq)(schema_1.friendships.id, id)); await upsertTombstone(tx, entity, id, deletedAt, userId); return; } } exports.default = router; //# sourceMappingURL=sync.js.map