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,
|
uuid,
|
||||||
text,
|
text,
|
||||||
integer,
|
integer,
|
||||||
|
serial,
|
||||||
timestamp,
|
timestamp,
|
||||||
jsonb,
|
jsonb,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
@@ -40,6 +41,7 @@ export interface ProgressNote {
|
|||||||
|
|
||||||
export const tasks = pgTable("tasks", {
|
export const tasks = pgTable("tasks", {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
taskNumber: serial("task_number").notNull(),
|
||||||
title: text("title").notNull(),
|
title: text("title").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
source: taskSourceEnum("source").notNull().default("donovan"),
|
source: taskSourceEnum("source").notNull().default("donovan"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { tasks, type ProgressNote } from "../db/schema";
|
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";
|
import { auth } from "../lib/auth";
|
||||||
|
|
||||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
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");
|
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" })
|
export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||||
.onError(({ error, set }) => {
|
.onError(({ error, set }) => {
|
||||||
const msg = error?.message || String(error);
|
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 update task - requires session or bearer auth
|
||||||
.patch(
|
.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
async ({ params, body, request, headers }) => {
|
async ({ params, body, request, headers }) => {
|
||||||
await requireSessionOrBearer(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() };
|
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||||
if (body.title !== undefined) updates.title = body.title;
|
if (body.title !== undefined) updates.title = body.title;
|
||||||
if (body.description !== undefined) updates.description = body.description;
|
if (body.description !== undefined) updates.description = body.description;
|
||||||
@@ -179,7 +211,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
const updated = await db
|
const updated = await db
|
||||||
.update(tasks)
|
.update(tasks)
|
||||||
.set(updates)
|
.set(updates)
|
||||||
.where(eq(tasks.id, params.id))
|
.where(eq(tasks.id, task.id))
|
||||||
.returning();
|
.returning();
|
||||||
if (!updated.length) throw new Error("Task not found");
|
if (!updated.length) throw new Error("Task not found");
|
||||||
|
|
||||||
@@ -208,13 +240,10 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
"/:id/notes",
|
"/:id/notes",
|
||||||
async ({ params, body, request, headers }) => {
|
async ({ params, body, request, headers }) => {
|
||||||
await requireSessionOrBearer(request, headers);
|
await requireSessionOrBearer(request, headers);
|
||||||
const existing = await db
|
const task = await resolveTask(params.id);
|
||||||
.select()
|
if (!task) throw new Error("Task not found");
|
||||||
.from(tasks)
|
|
||||||
.where(eq(tasks.id, params.id));
|
|
||||||
if (!existing.length) throw new Error("Task not found");
|
|
||||||
|
|
||||||
const currentNotes = (existing[0].progressNotes || []) as ProgressNote[];
|
const currentNotes = (task.progressNotes || []) as ProgressNote[];
|
||||||
const newNote: ProgressNote = {
|
const newNote: ProgressNote = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
note: body.note,
|
note: body.note,
|
||||||
@@ -224,7 +253,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
const updated = await db
|
const updated = await db
|
||||||
.update(tasks)
|
.update(tasks)
|
||||||
.set({ progressNotes: currentNotes, updatedAt: new Date() })
|
.set({ progressNotes: currentNotes, updatedAt: new Date() })
|
||||||
.where(eq(tasks.id, params.id))
|
.where(eq(tasks.id, task.id))
|
||||||
.returning();
|
.returning();
|
||||||
return updated[0];
|
return updated[0];
|
||||||
},
|
},
|
||||||
@@ -259,11 +288,9 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
"/:id",
|
"/:id",
|
||||||
async ({ params, request, headers }) => {
|
async ({ params, request, headers }) => {
|
||||||
await requireSessionOrBearer(request, headers);
|
await requireSessionOrBearer(request, headers);
|
||||||
const deleted = await db
|
const task = await resolveTask(params.id);
|
||||||
.delete(tasks)
|
if (!task) throw new Error("Task not found");
|
||||||
.where(eq(tasks.id, params.id))
|
await db.delete(tasks).where(eq(tasks.id, task.id));
|
||||||
.returning();
|
|
||||||
if (!deleted.length) throw new Error("Task not found");
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,18 +66,9 @@ export function TaskCard({
|
|||||||
isActive,
|
isActive,
|
||||||
onClick,
|
onClick,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
const [idCopied, setIdCopied] = useState(false);
|
|
||||||
const actions = statusActions[task.status] || [];
|
const actions = statusActions[task.status] || [];
|
||||||
const noteCount = task.progressNotes?.length || 0;
|
const noteCount = task.progressNotes?.length || 0;
|
||||||
const shortId = task.id.slice(0, 8);
|
const displayId = task.taskNumber ? `HQ-${task.taskNumber}` : task.id.slice(0, 8);
|
||||||
|
|
||||||
const handleCopyId = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigator.clipboard.writeText(task.id).then(() => {
|
|
||||||
setIdCopied(true);
|
|
||||||
setTimeout(() => setIdCopied(false), 1500);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -104,14 +95,14 @@ export function TaskCard({
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<span
|
<span
|
||||||
className="text-xs px-1.5 py-0.5 rounded font-mono bg-gray-100 text-gray-500 cursor-pointer hover:bg-gray-200 transition"
|
className="text-xs px-1.5 py-0.5 rounded font-mono font-bold bg-amber-100 text-amber-700 cursor-pointer hover:bg-amber-200 transition"
|
||||||
title={`Click to copy: ${task.id}`}
|
title={`Click to copy: ${displayId}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.writeText(task.id);
|
navigator.clipboard.writeText(displayId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{task.id.slice(0, 8)}
|
{displayId}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}>
|
||||||
{task.priority}
|
{task.priority}
|
||||||
@@ -127,17 +118,6 @@ export function TaskCard({
|
|||||||
💬 {noteCount}
|
💬 {noteCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={handleCopyId}
|
|
||||||
className="text-xs font-mono text-gray-300 hover:text-amber-600 transition cursor-pointer ml-auto"
|
|
||||||
title={`Copy full ID: ${task.id}`}
|
|
||||||
>
|
|
||||||
{idCopied ? (
|
|
||||||
<span className="text-green-500">✓ copied</span>
|
|
||||||
) : (
|
|
||||||
<span>{shortId}…</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.description && (
|
{task.description && (
|
||||||
|
|||||||
@@ -118,30 +118,46 @@ function ElapsedTimer({ since }: { since: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyableId({ id }: { id: string }) {
|
function CopyableId({ id, taskNumber }: { id: string; taskNumber?: number }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
const displayId = taskNumber ? `HQ-${taskNumber}` : null;
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = (value: string, label: string) => {
|
||||||
navigator.clipboard.writeText(id).then(() => {
|
navigator.clipboard.writeText(value).then(() => {
|
||||||
setCopied(true);
|
setCopied(label);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(null), 2000);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 py-3 border-t border-gray-100 bg-gray-50 flex items-center gap-2">
|
<div className="px-6 py-3 border-t border-gray-100 bg-gray-50 flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-400">ID:</span>
|
{displayId && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs font-bold text-amber-700 bg-amber-100 px-2 py-0.5 rounded font-mono">{displayId}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(displayId, "ref")}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-md border transition font-medium ${
|
||||||
|
copied === "ref"
|
||||||
|
? "bg-green-50 text-green-600 border-green-200"
|
||||||
|
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copied === "ref" ? "✓" : "📋"}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<code className="text-xs text-gray-400 font-mono flex-1 truncate select-all">{id}</code>
|
<code className="text-xs text-gray-400 font-mono flex-1 truncate select-all">{id}</code>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={() => handleCopy(id, "uuid")}
|
||||||
className={`text-xs px-2.5 py-1 rounded-md border transition font-medium ${
|
className={`text-xs px-2.5 py-1 rounded-md border transition font-medium ${
|
||||||
copied
|
copied === "uuid"
|
||||||
? "bg-green-50 text-green-600 border-green-200"
|
? "bg-green-50 text-green-600 border-green-200"
|
||||||
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-100 hover:text-gray-700"
|
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-100 hover:text-gray-700"
|
||||||
}`}
|
}`}
|
||||||
title="Copy task ID"
|
title="Copy UUID"
|
||||||
>
|
>
|
||||||
{copied ? "✓ Copied" : "📋 Copy"}
|
{copied === "uuid" ? "✓ Copied" : "📋 Copy UUID"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -189,7 +205,10 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, hasToken }: Tas
|
|||||||
{task.source}
|
{task.source}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-bold text-gray-900 leading-snug">{task.title}</h2>
|
<h2 className="text-lg font-bold text-gray-900 leading-snug">
|
||||||
|
{task.taskNumber && <span className="text-amber-600 mr-2">HQ-{task.taskNumber}</span>}
|
||||||
|
{task.title}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -308,7 +327,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, hasToken }: Tas
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Task ID - click to copy */}
|
{/* Task ID - click to copy */}
|
||||||
<CopyableId id={task.id} />
|
<CopyableId id={task.id} taskNumber={task.taskNumber} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface ProgressNote {
|
|||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
|
taskNumber: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
source: TaskSource;
|
source: TaskSource;
|
||||||
|
|||||||
Reference in New Issue
Block a user