fixxed color selector in the category settings and fixxed drag and drop
Build APK / build (push) Canceled after 2m0s
Build APK / build (push) Canceled after 2m0s
subtask
This commit is contained in:
+37
-14
@@ -1,6 +1,9 @@
|
||||
import { pgTable, text, integer, bigint, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, text, integer, bigint, boolean, unique, index, primaryKey, AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const ENTITIES = ['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships'] as const;
|
||||
export type EntityName = (typeof ENTITIES)[number];
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
username: text('username').notNull().unique(),
|
||||
@@ -40,7 +43,7 @@ export const categories = pgTable('categories', {
|
||||
export const tasks = pgTable('tasks', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
categoryId: text('category_id').notNull().references(() => categories.id, { onDelete: 'cascade' }),
|
||||
categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description').notNull().default(''),
|
||||
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||
@@ -56,26 +59,31 @@ export const tasks = pgTable('tasks', {
|
||||
assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
reminder: text('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
|
||||
reminders: text('reminders').notNull().default(''),
|
||||
completedAt: bigint('completed_at', { mode: 'number' }),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
});
|
||||
|
||||
export const repeatProfiles = pgTable('repeat_profiles', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
|
||||
repeatInterval: integer('repeat_interval').notNull().default(1),
|
||||
repeatDays: text('repeat_days').notNull().default(''),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
});
|
||||
export const tombstones = pgTable(
|
||||
'tombstones',
|
||||
{
|
||||
entity: text('entity', { enum: ENTITIES }).notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.entity, t.entityId] }),
|
||||
userIdx: index('tombstones_user_updated_idx').on(t.userId, t.updatedAt),
|
||||
})
|
||||
);
|
||||
|
||||
// Define subtasks with explicit type to avoid circular reference
|
||||
export const subtasks = pgTable('subtasks', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
|
||||
parentSubtaskId: text('parent_subtask_id').references(() => subtasks.id, { onDelete: 'cascade' }),
|
||||
parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description').notNull().default(''),
|
||||
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||
@@ -94,6 +102,21 @@ export const subtasks = pgTable('subtasks', {
|
||||
order: integer('order').notNull().default(0),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
}, (t) => ({
|
||||
parentIdx: index('subtasks_parent_idx').on(t.parentSubtaskId),
|
||||
taskIdx: index('subtasks_task_idx').on(t.taskId),
|
||||
userIdx: index('subtasks_user_idx').on(t.userId),
|
||||
}));
|
||||
|
||||
export const repeatProfiles = pgTable('repeat_profiles', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
|
||||
repeatInterval: integer('repeat_interval').notNull().default(1),
|
||||
repeatDays: text('repeat_days').notNull().default(''),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
});
|
||||
|
||||
export const userSettings = pgTable('user_settings', {
|
||||
@@ -105,4 +128,4 @@ export const userSettings = pgTable('user_settings', {
|
||||
sortBy: text('sort_by', { enum: ['dueDate', 'priority', 'title', 'createdAt'] }).notNull().default('dueDate'),
|
||||
sortOrder: text('sort_order', { enum: ['asc', 'desc'] }).notNull().default('asc'),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
|
||||
import { asyncHandler } from '../utils/asyncHandler';
|
||||
import { db } from '../db';
|
||||
import { subtasks, tasks } from '../db/schema';
|
||||
import { eq, and, asc, desc } from 'drizzle-orm';
|
||||
import { eq, and, asc, desc, isNull } from 'drizzle-orm';
|
||||
import { authMiddleware } from '../utils/auth';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation';
|
||||
@@ -70,7 +70,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
|
||||
.where(and(
|
||||
eq(subtasks.taskId, req.params.taskId),
|
||||
eq(subtasks.userId, userId),
|
||||
parentSubtaskId ? eq(subtasks.parentSubtaskId, parentSubtaskId) : eq(subtasks.parentSubtaskId, null)
|
||||
parentSubtaskId ? eq(subtasks.parentSubtaskId, parentSubtaskId) : isNull(subtasks.parentSubtaskId)
|
||||
))
|
||||
.orderBy(desc(subtasks.order))
|
||||
.limit(1);
|
||||
@@ -117,7 +117,7 @@ router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
|
||||
const data = subtaskUpdateSchema.parse(req.body);
|
||||
const userId = req.user!.userId;
|
||||
|
||||
const existing = await db
|
||||
const existing: any[] = await db
|
||||
.select()
|
||||
.from(subtasks)
|
||||
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
|
||||
|
||||
+261
-29
@@ -1,8 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { asyncHandler } from '../utils/asyncHandler';
|
||||
import { db } from '../db';
|
||||
import { categories, tasks, subtasks, repeatProfiles, users, friendships } from '../db/schema';
|
||||
import { eq, and, gte, lte, asc, or, inArray, sql } from 'drizzle-orm';
|
||||
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';
|
||||
@@ -32,17 +32,15 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
.orderBy(asc(tasks.updatedAt));
|
||||
|
||||
// Fetch subtasks changed since timestamp
|
||||
const taskIds = changedTasks.map(t => t.id);
|
||||
let changedSubtasks: any[] = [];
|
||||
|
||||
if (taskIds.length > 0) {
|
||||
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 (we track by updatedAt)
|
||||
// Also fetch subtasks for tasks that might have been deleted
|
||||
changedSubtasks = await db
|
||||
.select()
|
||||
.from(subtasks)
|
||||
@@ -67,6 +65,13 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
))
|
||||
.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({
|
||||
@@ -75,6 +80,7 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
subtasks: changedSubtasks,
|
||||
repeatProfiles: changedRepeatProfiles,
|
||||
friendships: changedFriendships,
|
||||
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
|
||||
timestamp,
|
||||
});
|
||||
}));
|
||||
@@ -125,6 +131,27 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -185,7 +212,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
resolution: 'server_wins',
|
||||
});
|
||||
continue; // Server wins
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent completing tasks with future due dates
|
||||
if (task.completed === true) {
|
||||
@@ -203,30 +230,32 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(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: task.assigneeId ?? null,
|
||||
updatedAt: task.updatedAt,
|
||||
})
|
||||
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
|
||||
.update(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(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,
|
||||
});
|
||||
@@ -260,6 +289,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
.update(subtasks)
|
||||
.set({
|
||||
taskId: sub.taskId,
|
||||
parentSubtaskId: sub.parentSubtaskId ?? null,
|
||||
title: sub.title,
|
||||
description: sub.description ?? '',
|
||||
priority: sub.priority ?? 'none',
|
||||
@@ -274,7 +304,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
seriesId: sub.seriesId ?? '',
|
||||
reminder: sub.reminder ?? 'none',
|
||||
reminders: sub.reminders ?? '',
|
||||
assigneeId: sub.assigneeId ?? null,
|
||||
assigneeId: sanitizeAssignee(sub.assigneeId),
|
||||
order: sub.order,
|
||||
updatedAt: sub.updatedAt,
|
||||
})
|
||||
@@ -282,6 +312,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
} else {
|
||||
await tx.insert(subtasks).values({
|
||||
...sub,
|
||||
assigneeId: sanitizeAssignee(sub.assigneeId),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
@@ -365,6 +396,14 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -378,4 +417,197 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
});
|
||||
}));
|
||||
|
||||
// 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;
|
||||
+18
-14
@@ -116,15 +116,17 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
const userId = req.user!.userId;
|
||||
const now = Date.now();
|
||||
|
||||
// Verify category exists and belongs to user
|
||||
const cat = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
|
||||
.limit(1);
|
||||
// Verify category exists and belongs to user (optional - tasks may be uncategorized)
|
||||
if (data.categoryId) {
|
||||
const cat = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (cat.length === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Category not found', 404);
|
||||
if (cat.length === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Category not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
@@ -261,12 +263,14 @@ router.post('/batch', asyncHandler(async (req: Request, res: Response) => {
|
||||
for (const op of operations) {
|
||||
try {
|
||||
if (op.type === 'create') {
|
||||
const cat = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
|
||||
.limit(1);
|
||||
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
|
||||
if (op.data.categoryId) {
|
||||
const cat = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
|
||||
.limit(1);
|
||||
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
|
||||
}
|
||||
|
||||
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const now = Date.now();
|
||||
|
||||
@@ -21,29 +21,44 @@ export const categoryUpdateSchema = z.object({
|
||||
|
||||
export const repeatSchema = z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
|
||||
|
||||
// The client stores '' / 0 for unset repeat values; normalize them so stale or
|
||||
// legacy rows never fail validation on push.
|
||||
export const repeatFieldSchema = repeatSchema
|
||||
.or(z.literal(''))
|
||||
.transform((v) => (v === '' ? 'none' : v))
|
||||
.optional();
|
||||
export const repeatIntervalFieldSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(30)
|
||||
.transform((v) => Math.max(1, v))
|
||||
.optional();
|
||||
|
||||
export const taskCreateSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
description: z.string().max(1000).optional(),
|
||||
categoryId: z.string().min(1),
|
||||
categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||
dueDate: z.number().int().min(0).optional(),
|
||||
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
|
||||
allDay: z.boolean().optional(),
|
||||
repeat: repeatSchema.optional(),
|
||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||
repeat: repeatFieldSchema,
|
||||
repeatInterval: repeatIntervalFieldSchema,
|
||||
repeatDays: z.string().max(20).optional(),
|
||||
seriesId: z.string().max(50).optional(),
|
||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||
reminders: z.string().max(100).optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
completedAt: z.number().int().min(0).nullable().optional(),
|
||||
subtasks: z.array(z.object({ title: z.string().min(1).max(100) })).optional(),
|
||||
});
|
||||
|
||||
export const taskUpdateSchema = z.object({
|
||||
title: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
categoryId: z.string().min(1).optional(),
|
||||
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||
completed: z.boolean().optional(),
|
||||
dueDate: z.number().int().min(0).optional(),
|
||||
@@ -51,6 +66,7 @@ export const taskUpdateSchema = z.object({
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
||||
reminders: z.string().max(100).optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
completedAt: z.number().int().min(0).nullable().optional(),
|
||||
});
|
||||
|
||||
export const subtaskCreateSchema = z.object({
|
||||
@@ -61,8 +77,8 @@ export const subtaskCreateSchema = z.object({
|
||||
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
|
||||
allDay: z.boolean().optional(),
|
||||
repeat: repeatSchema.optional(),
|
||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||
repeat: repeatFieldSchema,
|
||||
repeatInterval: repeatIntervalFieldSchema,
|
||||
repeatDays: z.string().max(20).optional(),
|
||||
seriesId: z.string().max(50).optional(),
|
||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||
@@ -81,8 +97,8 @@ export const subtaskUpdateSchema = z.object({
|
||||
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v === '' ? undefined : v),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
||||
allDay: z.boolean().optional(),
|
||||
repeat: repeatSchema.optional(),
|
||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||
repeat: repeatFieldSchema,
|
||||
repeatInterval: repeatIntervalFieldSchema,
|
||||
repeatDays: z.string().max(20).optional(),
|
||||
seriesId: z.string().max(50).optional(),
|
||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||
@@ -132,14 +148,21 @@ export const friendshipSchema = z.object({
|
||||
updatedAt: z.number(),
|
||||
});
|
||||
|
||||
export const tombstoneSchema = z.object({
|
||||
entity: z.enum(['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships']),
|
||||
id: z.string(),
|
||||
updatedAt: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export const pushChangesSchema = z.object({
|
||||
changes: z.object({
|
||||
categories: z.array(categoryCreateSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||
tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||
tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number(), completedAt: z.number().int().min(0).nullable().optional() })).optional(),
|
||||
subtasks: z.array(subtaskCreateSchema.extend({ id: z.string(), taskId: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||
repeatProfiles: z.array(repeatProfileSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||
friendships: z.array(friendshipSchema).optional(),
|
||||
}),
|
||||
deleted: z.array(tombstoneSchema).optional(),
|
||||
lastPulledAt: z.number().int().min(0),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user