From be9ba65337eafbb233cc3b59b1a92b3638272296 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 6 Apr 2026 11:47:53 -0400 Subject: [PATCH] feat: implement REST API backend with full CRUD and sync (#37) - Lists, Tasks, Tags CRUD endpoints with soft-delete - Sync endpoints (GET since + POST batch with idempotency keys) - WS ticket endpoint (ephemeral nonce, 30s TTL, single use) - Auth middleware on all endpoints via getAuthenticatedUser() - BOLA prevention: userId check on every entity operation - Zod strict schemas for input validation - Filters and sorting on task listing Co-Authored-By: Claude Opus 4.6 (1M context) --- web/package-lock.json | 4 +- web/package.json | 3 +- web/src/app/api/lists/[id]/route.ts | 52 ++++ web/src/app/api/lists/[id]/tasks/route.ts | 85 +++++++ web/src/app/api/lists/reorder/route.ts | 36 +++ web/src/app/api/lists/route.ts | 34 +++ web/src/app/api/sync/route.ts | 228 ++++++++++++++++++ web/src/app/api/tags/[id]/route.ts | 52 ++++ web/src/app/api/tags/route.ts | 34 +++ web/src/app/api/tasks/[id]/route.ts | 67 +++++ web/src/app/api/tasks/[id]/subtasks/route.ts | 39 +++ .../app/api/tasks/[id]/tags/[tagId]/route.ts | 31 +++ web/src/app/api/tasks/[id]/tags/route.ts | 47 ++++ web/src/app/api/tasks/reorder/route.ts | 35 +++ web/src/app/api/tasks/route.ts | 47 ++++ web/src/app/api/ws-ticket/route.ts | 46 ++++ web/src/lib/api.ts | 33 +++ web/src/lib/validators.ts | 71 ++++++ 18 files changed, 941 insertions(+), 3 deletions(-) create mode 100644 web/src/app/api/lists/[id]/route.ts create mode 100644 web/src/app/api/lists/[id]/tasks/route.ts create mode 100644 web/src/app/api/lists/reorder/route.ts create mode 100644 web/src/app/api/lists/route.ts create mode 100644 web/src/app/api/sync/route.ts create mode 100644 web/src/app/api/tags/[id]/route.ts create mode 100644 web/src/app/api/tags/route.ts create mode 100644 web/src/app/api/tasks/[id]/route.ts create mode 100644 web/src/app/api/tasks/[id]/subtasks/route.ts create mode 100644 web/src/app/api/tasks/[id]/tags/[tagId]/route.ts create mode 100644 web/src/app/api/tasks/[id]/tags/route.ts create mode 100644 web/src/app/api/tasks/reorder/route.ts create mode 100644 web/src/app/api/tasks/route.ts create mode 100644 web/src/app/api/ws-ticket/route.ts create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/validators.ts diff --git a/web/package-lock.json b/web/package-lock.json index 9ae3d7b..a5818f6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,7 +15,8 @@ "next": "16.2.2", "pg": "^8.20.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -8497,7 +8498,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/web/package.json b/web/package.json index c380719..2823426 100644 --- a/web/package.json +++ b/web/package.json @@ -16,7 +16,8 @@ "next": "16.2.2", "pg": "^8.20.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/web/src/app/api/lists/[id]/route.ts b/web/src/app/api/lists/[id]/route.ts new file mode 100644 index 0000000..5d78685 --- /dev/null +++ b/web/src/app/api/lists/[id]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slLists } from '@/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth, parseBody } from '@/lib/api'; +import { updateListSchema } from '@/lib/validators'; + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const { id } = await params; + const body = await parseBody(request, (d) => updateListSchema.parse(d)); + if (body.error) return body.error; + + const [updated] = await db + .update(slLists) + .set({ ...body.data, updatedAt: new Date() }) + .where(and(eq(slLists.id, id), eq(slLists.userId, auth.userId))) + .returning(); + + if (!updated) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json(updated); +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const { id } = await params; + + const [deleted] = await db + .update(slLists) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(slLists.id, id), eq(slLists.userId, auth.userId))) + .returning(); + + if (!deleted) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/web/src/app/api/lists/[id]/tasks/route.ts b/web/src/app/api/lists/[id]/tasks/route.ts new file mode 100644 index 0000000..35f866b --- /dev/null +++ b/web/src/app/api/lists/[id]/tasks/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slTasks, slLists, slTaskTags } from '@/db/schema'; +import { eq, and, isNull, asc, desc, inArray, SQL } from 'drizzle-orm'; +import { requireAuth } from '@/lib/api'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const { id: listId } = await params; + + // Verify list belongs to user + const [list] = await db + .select({ id: slLists.id }) + .from(slLists) + .where(and(eq(slLists.id, listId), eq(slLists.userId, auth.userId))); + + if (!list) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }); + } + + const url = request.nextUrl; + const completed = url.searchParams.get('completed'); + const priority = url.searchParams.get('priority'); + const dueDate = url.searchParams.get('dueDate'); + const tags = url.searchParams.get('tags'); + const sortBy = url.searchParams.get('sortBy') || 'position'; + const sortOrder = url.searchParams.get('sortOrder') || 'asc'; + + const conditions: SQL[] = [ + eq(slTasks.listId, listId), + eq(slTasks.userId, auth.userId), + isNull(slTasks.deletedAt), + isNull(slTasks.parentId), + ]; + + if (completed !== null) { + conditions.push(eq(slTasks.completed, completed === 'true')); + } + if (priority !== null) { + conditions.push(eq(slTasks.priority, parseInt(priority, 10))); + } + + // Build query + let query = db + .select() + .from(slTasks) + .where(and(...conditions)); + + // Sort + const sortColumn = sortBy === 'priority' ? slTasks.priority + : sortBy === 'dueDate' ? slTasks.dueDate + : sortBy === 'createdAt' ? slTasks.createdAt + : sortBy === 'title' ? slTasks.title + : slTasks.position; + + const orderFn = sortOrder === 'desc' ? desc : asc; + const tasks = await query.orderBy(orderFn(sortColumn)); + + // Filter by tags if specified (post-query since it's a join table) + if (tags) { + const tagIds = tags.split(','); + const taskTagRows = await db + .select({ taskId: slTaskTags.taskId }) + .from(slTaskTags) + .where(inArray(slTaskTags.tagId, tagIds)); + + const taskIdsWithTags = new Set(taskTagRows.map((r) => r.taskId)); + return NextResponse.json(tasks.filter((t) => taskIdsWithTags.has(t.id))); + } + + // Filter by dueDate if specified (before/on that date) + if (dueDate) { + const cutoff = new Date(dueDate); + return NextResponse.json( + tasks.filter((t) => t.dueDate && t.dueDate <= cutoff) + ); + } + + return NextResponse.json(tasks); +} diff --git a/web/src/app/api/lists/reorder/route.ts b/web/src/app/api/lists/reorder/route.ts new file mode 100644 index 0000000..d680523 --- /dev/null +++ b/web/src/app/api/lists/reorder/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slLists } from '@/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { requireAuth, parseBody } from '@/lib/api'; +import { reorderSchema } from '@/lib/validators'; + +export async function PUT(request: Request) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const body = await parseBody(request, (d) => reorderSchema.parse(d)); + if (body.error) return body.error; + + // Verify all lists belong to user + const existing = await db + .select({ id: slLists.id }) + .from(slLists) + .where(and(eq(slLists.userId, auth.userId), inArray(slLists.id, body.data.ids))); + + if (existing.length !== body.data.ids.length) { + return NextResponse.json({ error: 'Some lists not found' }, { status: 404 }); + } + + // Update positions in order + await Promise.all( + body.data.ids.map((id, index) => + db + .update(slLists) + .set({ position: index, updatedAt: new Date() }) + .where(and(eq(slLists.id, id), eq(slLists.userId, auth.userId))) + ) + ); + + return NextResponse.json({ ok: true }); +} diff --git a/web/src/app/api/lists/route.ts b/web/src/app/api/lists/route.ts new file mode 100644 index 0000000..7a2d47f --- /dev/null +++ b/web/src/app/api/lists/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slLists } from '@/db/schema'; +import { eq, isNull, and, asc } from 'drizzle-orm'; +import { requireAuth, parseBody } from '@/lib/api'; +import { createListSchema } from '@/lib/validators'; + +export async function GET() { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const lists = await db + .select() + .from(slLists) + .where(and(eq(slLists.userId, auth.userId), isNull(slLists.deletedAt))) + .orderBy(asc(slLists.position)); + + return NextResponse.json(lists); +} + +export async function POST(request: Request) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const body = await parseBody(request, (d) => createListSchema.parse(d)); + if (body.error) return body.error; + + const [list] = await db + .insert(slLists) + .values({ ...body.data, userId: auth.userId }) + .returning(); + + return NextResponse.json(list, { status: 201 }); +} diff --git a/web/src/app/api/sync/route.ts b/web/src/app/api/sync/route.ts new file mode 100644 index 0000000..6d527f2 --- /dev/null +++ b/web/src/app/api/sync/route.ts @@ -0,0 +1,228 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slLists, slTasks, slTags, slTaskTags } from '@/db/schema'; +import { eq, and, gte } from 'drizzle-orm'; +import { requireAuth, parseBody } from '@/lib/api'; +import { syncPushSchema, type SyncOperation } from '@/lib/validators'; + +// Idempotency key store (TTL 24h) +const idempotencyStore = new Map(); + +// Cleanup expired keys periodically +function cleanupIdempotencyKeys() { + const now = Date.now(); + for (const [key, entry] of idempotencyStore) { + if (entry.expiresAt < now) { + idempotencyStore.delete(key); + } + } +} + +const TTL_24H = 24 * 60 * 60 * 1000; + +export async function GET(request: NextRequest) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const since = request.nextUrl.searchParams.get('since'); + if (!since) { + return NextResponse.json({ error: 'Missing "since" parameter' }, { status: 400 }); + } + + const sinceDate = new Date(since); + if (isNaN(sinceDate.getTime())) { + return NextResponse.json({ error: 'Invalid "since" timestamp' }, { status: 400 }); + } + + // Fetch all entities updated since timestamp (including soft-deleted) + const [lists, tasks, tags] = await Promise.all([ + db + .select() + .from(slLists) + .where(and(eq(slLists.userId, auth.userId), gte(slLists.updatedAt, sinceDate))), + db + .select() + .from(slTasks) + .where(and(eq(slTasks.userId, auth.userId), gte(slTasks.updatedAt, sinceDate))), + db + .select() + .from(slTags) + .where(and(eq(slTags.userId, auth.userId), gte(slTags.createdAt, sinceDate))), + ]); + + // Get task-tag relations for the affected tasks + const taskIds = tasks.map((t) => t.id); + let taskTags: { taskId: string; tagId: string }[] = []; + if (taskIds.length > 0) { + const { inArray } = await import('drizzle-orm'); + taskTags = await db + .select() + .from(slTaskTags) + .where(inArray(slTaskTags.taskId, taskIds)); + } + + return NextResponse.json({ + lists, + tasks, + tags, + taskTags, + syncedAt: new Date().toISOString(), + }); +} + +export async function POST(request: Request) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const body = await parseBody(request, (d) => syncPushSchema.parse(d)); + if (body.error) return body.error; + + cleanupIdempotencyKeys(); + + const results: { idempotencyKey: string; status: 'applied' | 'skipped'; error?: string }[] = []; + + for (const op of body.data.operations) { + const storeKey = `${auth.userId}:${op.idempotencyKey}`; + + // Check idempotency + const existing = idempotencyStore.get(storeKey); + if (existing && existing.expiresAt > Date.now()) { + results.push({ idempotencyKey: op.idempotencyKey, status: 'skipped' }); + continue; + } + + try { + await processOperation(op, auth.userId); + idempotencyStore.set(storeKey, { + result: true, + expiresAt: Date.now() + TTL_24H, + }); + results.push({ idempotencyKey: op.idempotencyKey, status: 'applied' }); + } catch (e) { + results.push({ + idempotencyKey: op.idempotencyKey, + status: 'skipped', + error: e instanceof Error ? e.message : 'Unknown error', + }); + } + } + + return NextResponse.json({ results, syncedAt: new Date().toISOString() }); +} + +async function processOperation(op: SyncOperation, userId: string) { + const { entityType, entityId, action, data } = op; + const now = new Date(); + + switch (entityType) { + case 'list': { + if (action === 'create') { + await db.insert(slLists).values({ + id: entityId, + userId, + name: (data as Record)?.name as string || 'Untitled', + color: (data as Record)?.color as string | undefined, + icon: (data as Record)?.icon as string | undefined, + position: (data as Record)?.position as number | undefined, + isInbox: (data as Record)?.isInbox as boolean | undefined, + }).onConflictDoNothing(); + } else if (action === 'update') { + await verifyOwnership(slLists, entityId, userId); + await db.update(slLists) + .set({ ...(data as Record), updatedAt: now }) + .where(and(eq(slLists.id, entityId), eq(slLists.userId, userId))); + } else if (action === 'delete') { + await verifyOwnership(slLists, entityId, userId); + await db.update(slLists) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(slLists.id, entityId), eq(slLists.userId, userId))); + } + break; + } + case 'task': { + if (action === 'create') { + const d = (data as Record) || {}; + await db.insert(slTasks).values({ + id: entityId, + userId, + title: d.title as string || 'Untitled', + listId: d.listId as string, + notes: d.notes as string | undefined, + priority: d.priority as number | undefined, + dueDate: d.dueDate ? new Date(d.dueDate as string) : undefined, + parentId: d.parentId as string | undefined, + recurrence: d.recurrence as string | undefined, + position: d.position as number | undefined, + }).onConflictDoNothing(); + } else if (action === 'update') { + await verifyOwnership(slTasks, entityId, userId); + const raw = { ...(data as Record), updatedAt: now } as Record; + if (raw.dueDate !== undefined) { + raw.dueDate = raw.dueDate ? new Date(raw.dueDate as string) : null; + } + await db.update(slTasks) + .set(raw) + .where(and(eq(slTasks.id, entityId), eq(slTasks.userId, userId))); + } else if (action === 'delete') { + await verifyOwnership(slTasks, entityId, userId); + await db.update(slTasks) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(slTasks.id, entityId), eq(slTasks.userId, userId))); + } + break; + } + case 'tag': { + if (action === 'create') { + const d = (data as Record) || {}; + await db.insert(slTags).values({ + id: entityId, + userId, + name: d.name as string || 'Untitled', + color: d.color as string | undefined, + }).onConflictDoNothing(); + } else if (action === 'update') { + await verifyTagOwnership(entityId, userId); + await db.update(slTags) + .set(data as Record) + .where(and(eq(slTags.id, entityId), eq(slTags.userId, userId))); + } else if (action === 'delete') { + await verifyTagOwnership(entityId, userId); + await db.update(slTags) + .set({ deletedAt: now }) + .where(and(eq(slTags.id, entityId), eq(slTags.userId, userId))); + } + break; + } + case 'taskTag': { + // entityId is used as taskId, tagId comes from data + const d = (data as Record) || {}; + const tagId = d.tagId as string; + if (action === 'create') { + await db.insert(slTaskTags) + .values({ taskId: entityId, tagId }) + .onConflictDoNothing(); + } else if (action === 'delete') { + await db.delete(slTaskTags) + .where(and(eq(slTaskTags.taskId, entityId), eq(slTaskTags.tagId, tagId))); + } + break; + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function verifyOwnership(table: any, entityId: string, userId: string) { + const [row] = await db + .select({ id: table.id }) + .from(table) + .where(and(eq(table.id, entityId), eq(table.userId, userId))); + if (!row) throw new Error('Entity not found or access denied'); +} + +async function verifyTagOwnership(entityId: string, userId: string) { + const [row] = await db + .select({ id: slTags.id }) + .from(slTags) + .where(and(eq(slTags.id, entityId), eq(slTags.userId, userId))); + if (!row) throw new Error('Tag not found or access denied'); +} diff --git a/web/src/app/api/tags/[id]/route.ts b/web/src/app/api/tags/[id]/route.ts new file mode 100644 index 0000000..606195d --- /dev/null +++ b/web/src/app/api/tags/[id]/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slTags } from '@/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth, parseBody } from '@/lib/api'; +import { updateTagSchema } from '@/lib/validators'; + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const { id } = await params; + const body = await parseBody(request, (d) => updateTagSchema.parse(d)); + if (body.error) return body.error; + + const [updated] = await db + .update(slTags) + .set(body.data) + .where(and(eq(slTags.id, id), eq(slTags.userId, auth.userId))) + .returning(); + + if (!updated) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json(updated); +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const { id } = await params; + + const [deleted] = await db + .update(slTags) + .set({ deletedAt: new Date() }) + .where(and(eq(slTags.id, id), eq(slTags.userId, auth.userId))) + .returning(); + + if (!deleted) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/web/src/app/api/tags/route.ts b/web/src/app/api/tags/route.ts new file mode 100644 index 0000000..082744a --- /dev/null +++ b/web/src/app/api/tags/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slTags } from '@/db/schema'; +import { eq, isNull, and, asc } from 'drizzle-orm'; +import { requireAuth, parseBody } from '@/lib/api'; +import { createTagSchema } from '@/lib/validators'; + +export async function GET() { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const tags = await db + .select() + .from(slTags) + .where(and(eq(slTags.userId, auth.userId), isNull(slTags.deletedAt))) + .orderBy(asc(slTags.name)); + + return NextResponse.json(tags); +} + +export async function POST(request: Request) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const body = await parseBody(request, (d) => createTagSchema.parse(d)); + if (body.error) return body.error; + + const [tag] = await db + .insert(slTags) + .values({ ...body.data, userId: auth.userId }) + .returning(); + + return NextResponse.json(tag, { status: 201 }); +} diff --git a/web/src/app/api/tasks/[id]/route.ts b/web/src/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..93e5df3 --- /dev/null +++ b/web/src/app/api/tasks/[id]/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slTasks } from '@/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth, parseBody } from '@/lib/api'; +import { updateTaskSchema } from '@/lib/validators'; + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const { id } = await params; + const body = await parseBody(request, (d) => updateTaskSchema.parse(d)); + if (body.error) return body.error; + + const updateData: Record = { + ...body.data, + updatedAt: new Date(), + }; + + // Convert dueDate string to Date + if (body.data.dueDate !== undefined) { + updateData.dueDate = body.data.dueDate ? new Date(body.data.dueDate) : null; + } + + // Set completedAt when toggling completed + if (body.data.completed !== undefined) { + updateData.completedAt = body.data.completed ? new Date() : null; + } + + const [updated] = await db + .update(slTasks) + .set(updateData) + .where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId))) + .returning(); + + if (!updated) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json(updated); +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const { id } = await params; + + const [deleted] = await db + .update(slTasks) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId))) + .returning(); + + if (!deleted) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/web/src/app/api/tasks/[id]/subtasks/route.ts b/web/src/app/api/tasks/[id]/subtasks/route.ts new file mode 100644 index 0000000..305ab55 --- /dev/null +++ b/web/src/app/api/tasks/[id]/subtasks/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slTasks } from '@/db/schema'; +import { eq, and, isNull, asc } from 'drizzle-orm'; +import { requireAuth } from '@/lib/api'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const { id } = await params; + + // Verify parent task belongs to user + const [parent] = await db + .select({ id: slTasks.id }) + .from(slTasks) + .where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId))); + + if (!parent) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }); + } + + const subtasks = await db + .select() + .from(slTasks) + .where( + and( + eq(slTasks.parentId, id), + eq(slTasks.userId, auth.userId), + isNull(slTasks.deletedAt) + ) + ) + .orderBy(asc(slTasks.position)); + + return NextResponse.json(subtasks); +} diff --git a/web/src/app/api/tasks/[id]/tags/[tagId]/route.ts b/web/src/app/api/tasks/[id]/tags/[tagId]/route.ts new file mode 100644 index 0000000..f340fac --- /dev/null +++ b/web/src/app/api/tasks/[id]/tags/[tagId]/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slTasks, slTaskTags } from '@/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth } from '@/lib/api'; + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string; tagId: string }> } +) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const { id: taskId, tagId } = await params; + + // Verify task belongs to user + const [task] = await db + .select({ id: slTasks.id }) + .from(slTasks) + .where(and(eq(slTasks.id, taskId), eq(slTasks.userId, auth.userId))); + + if (!task) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }); + } + + await db + .delete(slTaskTags) + .where(and(eq(slTaskTags.taskId, taskId), eq(slTaskTags.tagId, tagId))); + + return NextResponse.json({ ok: true }); +} diff --git a/web/src/app/api/tasks/[id]/tags/route.ts b/web/src/app/api/tasks/[id]/tags/route.ts new file mode 100644 index 0000000..76f579c --- /dev/null +++ b/web/src/app/api/tasks/[id]/tags/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slTasks, slTags, slTaskTags } from '@/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { requireAuth, parseBody } from '@/lib/api'; +import { assignTagsSchema } from '@/lib/validators'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const { id: taskId } = await params; + + const body = await parseBody(request, (d) => assignTagsSchema.parse(d)); + if (body.error) return body.error; + + // Verify task belongs to user + const [task] = await db + .select({ id: slTasks.id }) + .from(slTasks) + .where(and(eq(slTasks.id, taskId), eq(slTasks.userId, auth.userId))); + + if (!task) { + return NextResponse.json({ error: 'Task not found' }, { status: 404 }); + } + + // Verify all tags belong to user + const existingTags = await db + .select({ id: slTags.id }) + .from(slTags) + .where(and(eq(slTags.userId, auth.userId), inArray(slTags.id, body.data.tagIds))); + + if (existingTags.length !== body.data.tagIds.length) { + return NextResponse.json({ error: 'Some tags not found' }, { status: 404 }); + } + + // Insert (ignore conflicts) + await db + .insert(slTaskTags) + .values(body.data.tagIds.map((tagId) => ({ taskId, tagId }))) + .onConflictDoNothing(); + + return NextResponse.json({ ok: true }, { status: 201 }); +} diff --git a/web/src/app/api/tasks/reorder/route.ts b/web/src/app/api/tasks/reorder/route.ts new file mode 100644 index 0000000..64fef6e --- /dev/null +++ b/web/src/app/api/tasks/reorder/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slTasks } from '@/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { requireAuth, parseBody } from '@/lib/api'; +import { reorderSchema } from '@/lib/validators'; + +export async function PUT(request: Request) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const body = await parseBody(request, (d) => reorderSchema.parse(d)); + if (body.error) return body.error; + + // Verify all tasks belong to user + const existing = await db + .select({ id: slTasks.id }) + .from(slTasks) + .where(and(eq(slTasks.userId, auth.userId), inArray(slTasks.id, body.data.ids))); + + if (existing.length !== body.data.ids.length) { + return NextResponse.json({ error: 'Some tasks not found' }, { status: 404 }); + } + + await Promise.all( + body.data.ids.map((id, index) => + db + .update(slTasks) + .set({ position: index, updatedAt: new Date() }) + .where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId))) + ) + ); + + return NextResponse.json({ ok: true }); +} diff --git a/web/src/app/api/tasks/route.ts b/web/src/app/api/tasks/route.ts new file mode 100644 index 0000000..4fec9d9 --- /dev/null +++ b/web/src/app/api/tasks/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db/client'; +import { slTasks, slLists } from '@/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { requireAuth, parseBody } from '@/lib/api'; +import { createTaskSchema } from '@/lib/validators'; + +export async function POST(request: Request) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + const body = await parseBody(request, (d) => createTaskSchema.parse(d)); + if (body.error) return body.error; + + // Verify list belongs to user + const [list] = await db + .select({ id: slLists.id }) + .from(slLists) + .where(and(eq(slLists.id, body.data.listId), eq(slLists.userId, auth.userId))); + + if (!list) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }); + } + + // If parentId, verify parent task belongs to user + if (body.data.parentId) { + const [parent] = await db + .select({ id: slTasks.id }) + .from(slTasks) + .where(and(eq(slTasks.id, body.data.parentId), eq(slTasks.userId, auth.userId))); + + if (!parent) { + return NextResponse.json({ error: 'Parent task not found' }, { status: 404 }); + } + } + + const [task] = await db + .insert(slTasks) + .values({ + ...body.data, + dueDate: body.data.dueDate ? new Date(body.data.dueDate) : undefined, + userId: auth.userId, + }) + .returning(); + + return NextResponse.json(task, { status: 201 }); +} diff --git a/web/src/app/api/ws-ticket/route.ts b/web/src/app/api/ws-ticket/route.ts new file mode 100644 index 0000000..3f21c1d --- /dev/null +++ b/web/src/app/api/ws-ticket/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { randomUUID } from 'crypto'; +import { requireAuth } from '@/lib/api'; + +// In-memory ticket store (TTL 30s, single use) +const ticketStore = new Map(); + +const TTL_30S = 30 * 1000; + +// Cleanup expired tickets +function cleanupTickets() { + const now = Date.now(); + for (const [key, entry] of ticketStore) { + if (entry.expiresAt < now) { + ticketStore.delete(key); + } + } +} + +/** + * Validate and consume a WS ticket. Returns userId if valid, null otherwise. + */ +export function consumeTicket(ticket: string): string | null { + const entry = ticketStore.get(ticket); + if (!entry || entry.expiresAt < Date.now()) { + ticketStore.delete(ticket); + return null; + } + ticketStore.delete(ticket); // Single use + return entry.userId; +} + +export async function POST() { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + cleanupTickets(); + + const ticket = randomUUID(); + ticketStore.set(ticket, { + userId: auth.userId, + expiresAt: Date.now() + TTL_30S, + }); + + return NextResponse.json({ ticket }, { status: 201 }); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..2fcae58 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,33 @@ +import { getAuthenticatedUser } from '@/lib/auth'; +import { NextResponse } from 'next/server'; + +/** + * Authenticate the request and return userId or a 401 response. + */ +export async function requireAuth(): Promise< + | { userId: string; error?: never } + | { userId?: never; error: NextResponse } +> { + const user = await getAuthenticatedUser(); + if (!user) { + return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }; + } + return { userId: user.userId }; +} + +/** + * Wrap a handler with JSON parse error handling. + */ +export async function parseBody(request: Request, parse: (data: unknown) => T): Promise< + | { data: T; error?: never } + | { data?: never; error: NextResponse } +> { + try { + const raw = await request.json(); + const data = parse(raw); + return { data }; + } catch (e) { + const message = e instanceof Error ? e.message : 'Invalid request body'; + return { error: NextResponse.json({ error: message }, { status: 400 }) }; + } +} diff --git a/web/src/lib/validators.ts b/web/src/lib/validators.ts new file mode 100644 index 0000000..7a065b8 --- /dev/null +++ b/web/src/lib/validators.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; + +// Lists +export const createListSchema = z.object({ + name: z.string().min(1).max(200), + color: z.string().max(20).optional(), + icon: z.string().max(50).optional(), +}).strict(); + +export const updateListSchema = z.object({ + name: z.string().min(1).max(200).optional(), + color: z.string().max(20).nullable().optional(), + icon: z.string().max(50).nullable().optional(), + position: z.number().int().min(0).optional(), + isInbox: z.boolean().optional(), +}).strict(); + +export const reorderSchema = z.object({ + ids: z.array(z.string().uuid()).min(1), +}).strict(); + +// Tasks +export const createTaskSchema = z.object({ + title: z.string().min(1).max(500), + listId: z.string().uuid(), + notes: z.string().max(5000).optional(), + priority: z.number().int().min(0).max(3).optional(), + dueDate: z.string().datetime().optional(), + parentId: z.string().uuid().optional(), + recurrence: z.string().max(100).optional(), +}).strict(); + +export const updateTaskSchema = z.object({ + title: z.string().min(1).max(500).optional(), + notes: z.string().max(5000).nullable().optional(), + completed: z.boolean().optional(), + priority: z.number().int().min(0).max(3).optional(), + dueDate: z.string().datetime().nullable().optional(), + listId: z.string().uuid().optional(), + parentId: z.string().uuid().nullable().optional(), + position: z.number().int().min(0).optional(), + recurrence: z.string().max(100).nullable().optional(), +}).strict(); + +// Tags +export const createTagSchema = z.object({ + name: z.string().min(1).max(100), + color: z.string().max(20).optional(), +}).strict(); + +export const updateTagSchema = z.object({ + name: z.string().min(1).max(100).optional(), + color: z.string().max(20).optional(), +}).strict(); + +export const assignTagsSchema = z.object({ + tagIds: z.array(z.string().uuid()).min(1), +}).strict(); + +// Sync +export const syncPushSchema = z.object({ + operations: z.array(z.object({ + idempotencyKey: z.string().min(1).max(200), + entityType: z.enum(['list', 'task', 'tag', 'taskTag']), + entityId: z.string().uuid(), + action: z.enum(['create', 'update', 'delete']), + data: z.record(z.string(), z.unknown()).optional(), + }).strict()), +}).strict(); + +export type SyncOperation = z.infer['operations'][number];