feat: tasks route stateId/stateGroup rewrite

Replace flat status enum with stateId/stateGroup references:
- Import states table; add state_group filter via EXISTS subquery
- Add module_id and cycle_id filter params to task list endpoint
- Validate stateId on create/update (404 if state not found)
- Auto-set completedAt when state group is 'completed', clear otherwise
- Zero references to old taskStatusEnum remain
This commit is contained in:
2026-09-07 18:37:12 +00:00
parent 28b2cbd095
commit 60fb37c4a3
+54 -2
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { db, tasks, taskTags, tags as tagsTable, activityFeed, scheduledJobs, projects, sections, links } from "@project-e/db";
import { db, tasks, states as statesTable, taskTags, tags as tagsTable, activityFeed, scheduledJobs, projects, sections, links } from "@project-e/db";
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
@@ -90,6 +90,9 @@ taskRoutes.get("/", async (c) => {
const filter = url.searchParams.get("filter") || undefined;
const sort = url.searchParams.get("sort") || "-created";
const stateId = url.searchParams.get("state_id");
const stateGroup = url.searchParams.get("state_group");
const moduleId = url.searchParams.get("module_id");
const cycleId = url.searchParams.get("cycle_id");
const priority = url.searchParams.get("priority");
const tag = url.searchParams.get("tag");
const search = url.searchParams.get("search");
@@ -144,6 +147,22 @@ taskRoutes.get("/", async (c) => {
if (sectionId) {
conditions.push(eq(tasks.sectionId, sectionId));
}
if (moduleId) {
conditions.push(eq(tasks.moduleId, moduleId));
}
if (cycleId) {
conditions.push(eq(tasks.cycleId, cycleId));
}
if (stateGroup) {
const groups = stateGroup.split(",") as any[];
conditions.push(
exists(
db.select({ one: sql`1` })
.from(statesTable)
.where(and(eq(statesTable.id, tasks.stateId), inArray(statesTable.group, groups)))
)
);
}
// Tag filter applied in SQL (EXISTS on the junction table) so it runs over
// the full dataset before pagination — filtering in-memory after fetching a
// page would miss tasks beyond the limit and report a wrong totalItems.
@@ -296,6 +315,21 @@ taskRoutes.post("/", async (c) => {
}
}
// Validate stateId exists and compute completedAt
let completedAt: Date | null = null;
if (data.stateId) {
const [state] = await db.select({ id: statesTable.id, group: statesTable.group })
.from(statesTable)
.where(eq(statesTable.id, data.stateId))
.limit(1);
if (!state) {
return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404);
}
if (state.group === "completed") {
completedAt = new Date();
}
}
const [task] = await db.insert(tasks).values({
title: data.title,
description: data.description ?? null,
@@ -312,6 +346,7 @@ taskRoutes.post("/", async (c) => {
order: data.order ?? 0,
customFields: data.customFields ?? {},
recurrenceRule: data.recurrenceRule ?? null,
completedAt,
}).returning();
const tagIdsToLink: string[] = [...(data.tagIds || [])];
@@ -539,6 +574,23 @@ taskRoutes.patch("/:id", async (c) => {
if (data.order !== undefined) updateValues.order = data.order;
if (data.customFields !== undefined) updateValues.customFields = data.customFields;
if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule;
// Validate stateId and compute completedAt when state changes
if (data.stateId !== undefined) {
if (data.stateId !== null) {
const [state] = await db.select({ id: statesTable.id, group: statesTable.group })
.from(statesTable)
.where(eq(statesTable.id, data.stateId))
.limit(1);
if (!state) {
return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404);
}
updateValues.completedAt = state.group === "completed" ? new Date() : null;
} else {
updateValues.completedAt = null;
}
}
updateValues.updatedAt = new Date();
const [updated] = await db.update(tasks)
@@ -733,7 +785,7 @@ taskRoutes.post("/:id/status", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Status endpoint replaced by state assignment (Phase 2)" } }, 404);
});
// GET /api/tasks/:id/history — Status change log (from activity feed)
// GET /api/tasks/:id/history — State change log (from activity feed)
taskRoutes.get("/:id/history", async (c) => {
try {
const user = await requireAuth(c);