615 lines
21 KiB
TypeScript
615 lines
21 KiB
TypeScript
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<string>();
|
|
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<number>`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<string>();
|
|
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<string>();
|
|
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<void> {
|
|
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<void> {
|
|
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; |