fixxed color selector in the category settings and fixxed drag and drop
Build APK / build (push) Canceled after 2m0s

subtask
This commit is contained in:
2026-08-09 21:42:54 +02:00
parent 4b6e87c979
commit 4c3d1a118c
99 changed files with 3335 additions and 920 deletions
+281 -5
View File
@@ -27,9 +27,8 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.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
const taskIds = changedTasks.map(t => t.id);
let changedSubtasks = [];
if (taskIds.length > 0) {
if (changedTasks.length > 0) {
changedSubtasks = await db_1.db
.select()
.from(schema_1.subtasks)
@@ -37,7 +36,7 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt));
}
else {
// Also fetch subtasks for tasks that might have been deleted (we track by updatedAt)
// Also fetch subtasks for tasks that might have been deleted
changedSubtasks = await db_1.db
.select()
.from(schema_1.subtasks)
@@ -56,6 +55,12 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.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,
@@ -63,6 +68,7 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
subtasks: changedSubtasks,
repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships,
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
timestamp,
});
}));
@@ -73,6 +79,65 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
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) {
@@ -131,6 +196,20 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
});
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({
@@ -140,14 +219,17 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
priority: task.priority,
completed: task.completed,
dueDate: task.dueDate,
dueTime: task.dueTime,
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',
assigneeId: task.assigneeId ?? null,
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)));
@@ -155,6 +237,8 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
else {
await tx.insert(schema_1.tasks).values({
...task,
assigneeId: sanitizeAssignee(task.assigneeId),
allDay: task.allDay ?? false,
userId,
});
}
@@ -184,8 +268,22 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.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,
})
@@ -194,6 +292,7 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
else {
await tx.insert(schema_1.subtasks).values({
...sub,
assigneeId: sanitizeAssignee(sub.assigneeId),
userId,
});
}
@@ -273,6 +372,13 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
}
}
}
// 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) {
@@ -285,5 +391,175 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
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