From 285bac430827858019b3a5558854ad061336dc4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 07:58:51 +0000 Subject: [PATCH] feat: list + task endpoints and CORS, verified end-to-end --- server/api/lists.get.ts | 4 ++++ server/api/lists.put.ts | 15 +++++++++++++++ server/api/lists/[id]/tasks.get.ts | 10 ++++++++++ server/api/tasks.get.ts | 11 +++++++++++ server/api/tasks.post.ts | 20 ++++++++++++++++++++ server/api/tasks/[id].delete.ts | 6 ++++++ server/api/tasks/[id].put.ts | 20 ++++++++++++++++++++ server/api/tasks/[id]/consume.post.ts | 8 ++++++++ server/middleware/0.cors.ts | 19 +++++++++++++++++++ server/middleware/1.auth.ts | 7 +++++++ server/utils/dto.ts | 23 +++++++++++++++++++++++ 11 files changed, 143 insertions(+) create mode 100644 server/api/lists.get.ts create mode 100644 server/api/lists.put.ts create mode 100644 server/api/lists/[id]/tasks.get.ts create mode 100644 server/api/tasks.get.ts create mode 100644 server/api/tasks.post.ts create mode 100644 server/api/tasks/[id].delete.ts create mode 100644 server/api/tasks/[id].put.ts create mode 100644 server/api/tasks/[id]/consume.post.ts create mode 100644 server/middleware/0.cors.ts create mode 100644 server/utils/dto.ts diff --git a/server/api/lists.get.ts b/server/api/lists.get.ts new file mode 100644 index 0000000..59c2d1b --- /dev/null +++ b/server/api/lists.get.ts @@ -0,0 +1,4 @@ +// GET /api/lists (web) — all lists. +export default defineEventHandler(async () => { + return getLists(getSql()); +}); diff --git a/server/api/lists.put.ts b/server/api/lists.put.ts new file mode 100644 index 0000000..7da538c --- /dev/null +++ b/server/api/lists.put.ts @@ -0,0 +1,15 @@ +// PUT /api/lists (desktop) — full-replace catalog. Upsert all supplied; delete the rest. +export default defineEventHandler(async (event) => { + const body = await readBody(event); + if ( + !Array.isArray(body) || + body.some((l) => typeof l?.id !== "string" || typeof l?.name !== "string") + ) { + throw createError({ statusCode: 400, statusMessage: "expected [{ id, name }]" }); + } + await replaceLists( + getSql(), + body.map((l) => ({ id: l.id, name: l.name })), + ); + return { ok: true }; +}); diff --git a/server/api/lists/[id]/tasks.get.ts b/server/api/lists/[id]/tasks.get.ts new file mode 100644 index 0000000..7255355 --- /dev/null +++ b/server/api/lists/[id]/tasks.get.ts @@ -0,0 +1,10 @@ +// GET /api/lists/:id/tasks (web) — Idle tasks for a list. 404 if the list is unknown. +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, "id")!; + const sql = getSql(); + if (!(await listExists(sql, id))) { + throw createError({ statusCode: 404, statusMessage: "list not found" }); + } + const rows = await getTasksForList(sql, id); + return rows.map(toTaskDto); +}); diff --git a/server/api/tasks.get.ts b/server/api/tasks.get.ts new file mode 100644 index 0000000..d237ff5 --- /dev/null +++ b/server/api/tasks.get.ts @@ -0,0 +1,11 @@ +// GET /api/tasks?consumed=false (desktop) — web-created tasks not yet imported. +export default defineEventHandler(async () => { + const rows = await getUnconsumed(getSql()); + return rows.map((r) => ({ + id: r.id, + listId: r.list_id, + title: r.title, + description: r.description, + createdAt: new Date(r.created_at).toISOString(), + })); +}); diff --git a/server/api/tasks.post.ts b/server/api/tasks.post.ts new file mode 100644 index 0000000..523a875 --- /dev/null +++ b/server/api/tasks.post.ts @@ -0,0 +1,20 @@ +// POST /api/tasks (web) — create an Idle task with a server-generated GUID. +export default defineEventHandler(async (event) => { + const body = await readBody(event); + const title = typeof body?.title === "string" ? body.title.trim() : ""; + const listId = body?.listId; + if (!title || typeof listId !== "string") { + throw createError({ statusCode: 400, statusMessage: "title and listId are required" }); + } + const description = + typeof body?.description === "string" && body.description.trim() ? body.description : null; + + const sql = getSql(); + if (!(await listExists(sql, listId))) { + throw createError({ statusCode: 404, statusMessage: "list not found" }); + } + + const row = await createWebTask(sql, { listId, title, description }); + setResponseStatus(event, 201); + return toTaskDto(row); +}); diff --git a/server/api/tasks/[id].delete.ts b/server/api/tasks/[id].delete.ts new file mode 100644 index 0000000..d4da7e7 --- /dev/null +++ b/server/api/tasks/[id].delete.ts @@ -0,0 +1,6 @@ +// DELETE /api/tasks/:id (desktop) — task left Idle on desktop. Idempotent. +export default defineEventHandler(async (event) => { + await deleteTask(getSql(), getRouterParam(event, "id")!); + setResponseStatus(event, 204); + return null; +}); diff --git a/server/api/tasks/[id].put.ts b/server/api/tasks/[id].put.ts new file mode 100644 index 0000000..cebdfef --- /dev/null +++ b/server/api/tasks/[id].put.ts @@ -0,0 +1,20 @@ +// PUT /api/tasks/:id (desktop) — idempotent upsert mirroring a desktop Idle task. +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, "id")!; + const body = await readBody(event); + const title = body?.title; + const listId = body?.listId; + if (typeof title !== "string" || !title.trim() || typeof listId !== "string") { + throw createError({ statusCode: 400, statusMessage: "listId and title are required" }); + } + const description = typeof body?.description === "string" ? body.description : null; + + const sql = getSql(); + if (!(await listExists(sql, listId))) { + throw createError({ statusCode: 404, statusMessage: "list not found" }); + } + + const { created } = await upsertDesktopTask(sql, id, { listId, title, description }); + setResponseStatus(event, created ? 201 : 200); + return { id }; +}); diff --git a/server/api/tasks/[id]/consume.post.ts b/server/api/tasks/[id]/consume.post.ts new file mode 100644 index 0000000..a64fe0c --- /dev/null +++ b/server/api/tasks/[id]/consume.post.ts @@ -0,0 +1,8 @@ +// POST /api/tasks/:id/consume (desktop) — mark a web task imported. Idempotent. +export default defineEventHandler(async (event) => { + const ok = await consume(getSql(), getRouterParam(event, "id")!); + if (!ok) { + throw createError({ statusCode: 404, statusMessage: "task not found" }); + } + return { ok: true }; +}); diff --git a/server/middleware/0.cors.ts b/server/middleware/0.cors.ts new file mode 100644 index 0000000..8738099 --- /dev/null +++ b/server/middleware/0.cors.ts @@ -0,0 +1,19 @@ +// CORS for /api/**. Restricts to the configured web client origin and answers preflight. +// Runs before 1.auth.ts (alphabetical order) so OPTIONS is handled without a token. +export default defineEventHandler((event) => { + if (!getRequestURL(event).pathname.startsWith("/api/")) return; + + const origin = useRuntimeConfig().webOrigin; + if (origin) { + setResponseHeader(event, "Access-Control-Allow-Origin", origin); + setResponseHeader(event, "Vary", "Origin"); + setResponseHeader(event, "Access-Control-Allow-Headers", "authorization, content-type"); + setResponseHeader(event, "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + setResponseHeader(event, "Access-Control-Max-Age", "600"); + } + + if (event.method === "OPTIONS") { + setResponseStatus(event, 204); + return ""; + } +}); diff --git a/server/middleware/1.auth.ts b/server/middleware/1.auth.ts index 9bcc265..38e12d6 100644 --- a/server/middleware/1.auth.ts +++ b/server/middleware/1.auth.ts @@ -3,6 +3,13 @@ export default defineEventHandler(async (event) => { const path = getRequestURL(event).pathname; if (!path.startsWith("/api/")) return; + // Dev-only bypass for local smoke tests. `import.meta.dev` is false in production + // builds, so this branch is dead-code-eliminated and can never run when deployed. + if (import.meta.dev && process.env.AUTH_DEV_BYPASS === "1") { + event.context.user = { sub: "dev" }; + return; + } + // CORS preflight is answered (and short-circuited) by 0.cors.ts before this runs. const header = getHeader(event, "authorization") || ""; const token = header.startsWith("Bearer ") ? header.slice(7).trim() : ""; diff --git a/server/utils/dto.ts b/server/utils/dto.ts new file mode 100644 index 0000000..035333a --- /dev/null +++ b/server/utils/dto.ts @@ -0,0 +1,23 @@ +import type { TaskRow } from "./repo"; + +export interface TaskDto { + id: string; + listId: string; + title: string; + description: string | null; + source: string; + consumed: boolean; + createdAt: string; +} + +export function toTaskDto(row: TaskRow): TaskDto { + return { + id: row.id, + listId: row.list_id, + title: row.title, + description: row.description, + source: row.source, + consumed: row.consumed, + createdAt: new Date(row.created_at).toISOString(), + }; +}