feat: sequential task IDs (HQ-1, HQ-2, etc.)
- Add serial task_number column to tasks table
- Display HQ-{number} on cards and detail panel
- API resolveTask() supports UUID, number, or HQ-N prefix
- GET /api/tasks/:id endpoint for single task lookup
- All PATCH/POST/DELETE endpoints resolve by number or UUID
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
serial,
|
||||
timestamp,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
@@ -40,6 +41,7 @@ export interface ProgressNote {
|
||||
|
||||
export const tasks = pgTable("tasks", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
taskNumber: serial("task_number").notNull(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
source: taskSourceEnum("source").notNull().default("donovan"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { tasks, type ProgressNote } from "../db/schema";
|
||||
import { eq, asc, desc, sql, inArray } from "drizzle-orm";
|
||||
import { eq, asc, desc, sql, inArray, or } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
|
||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||
@@ -68,6 +68,23 @@ async function requireSessionOrBearer(request: Request, headers: Record<string,
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Resolve a task by UUID or sequential number (e.g. "5" or "HQ-5")
|
||||
async function resolveTask(idOrNumber: string) {
|
||||
// Strip "HQ-" prefix if present
|
||||
const cleaned = idOrNumber.replace(/^HQ-/i, "");
|
||||
const asNumber = parseInt(cleaned, 10);
|
||||
|
||||
let result;
|
||||
if (!isNaN(asNumber) && String(asNumber) === cleaned) {
|
||||
// Lookup by task_number
|
||||
result = await db.select().from(tasks).where(eq(tasks.taskNumber, asNumber));
|
||||
} else {
|
||||
// Lookup by UUID
|
||||
result = await db.select().from(tasks).where(eq(tasks.id, cleaned));
|
||||
}
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = error?.message || String(error);
|
||||
@@ -151,11 +168,26 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
}
|
||||
)
|
||||
|
||||
// GET single task by ID or number - requires session or bearer auth
|
||||
.get(
|
||||
"/:id",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const task = await resolveTask(params.id);
|
||||
if (!task) throw new Error("Task not found");
|
||||
return task;
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) }
|
||||
)
|
||||
|
||||
// PATCH update task - requires session or bearer auth
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const task = await resolveTask(params.id);
|
||||
if (!task) throw new Error("Task not found");
|
||||
|
||||
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||
if (body.title !== undefined) updates.title = body.title;
|
||||
if (body.description !== undefined) updates.description = body.description;
|
||||
@@ -179,7 +211,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
const updated = await db
|
||||
.update(tasks)
|
||||
.set(updates)
|
||||
.where(eq(tasks.id, params.id))
|
||||
.where(eq(tasks.id, task.id))
|
||||
.returning();
|
||||
if (!updated.length) throw new Error("Task not found");
|
||||
|
||||
@@ -208,13 +240,10 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
"/:id/notes",
|
||||
async ({ params, body, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, params.id));
|
||||
if (!existing.length) throw new Error("Task not found");
|
||||
const task = await resolveTask(params.id);
|
||||
if (!task) throw new Error("Task not found");
|
||||
|
||||
const currentNotes = (existing[0].progressNotes || []) as ProgressNote[];
|
||||
const currentNotes = (task.progressNotes || []) as ProgressNote[];
|
||||
const newNote: ProgressNote = {
|
||||
timestamp: new Date().toISOString(),
|
||||
note: body.note,
|
||||
@@ -224,7 +253,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
const updated = await db
|
||||
.update(tasks)
|
||||
.set({ progressNotes: currentNotes, updatedAt: new Date() })
|
||||
.where(eq(tasks.id, params.id))
|
||||
.where(eq(tasks.id, task.id))
|
||||
.returning();
|
||||
return updated[0];
|
||||
},
|
||||
@@ -259,11 +288,9 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
"/:id",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const deleted = await db
|
||||
.delete(tasks)
|
||||
.where(eq(tasks.id, params.id))
|
||||
.returning();
|
||||
if (!deleted.length) throw new Error("Task not found");
|
||||
const task = await resolveTask(params.id);
|
||||
if (!task) throw new Error("Task not found");
|
||||
await db.delete(tasks).where(eq(tasks.id, task.id));
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user