feat: plane-lift schema (states/modules/cycles/links)
Phase 1 of the Plane feature lift into Project E. Schema changes: - Add stateGroupEnum, moduleStatusEnum, linkTypeEnum - Add states table (per-project workflow states with group enum) - Add modules table (project-scoped planning buckets) - Add cycles table (time-boxed sprints) - Add links table (canonical cross-entity mesh) - Drop taskStatusEnum and tasks.status column - Add stateId, moduleId, cycleId FKs to tasks - Drop taskDependencies, noteLinks, noteEntityLinks tables Project creation bootstrap: - Seed 5 default states (Backlog/Todo/In Progress/Done/Cancelled) on new project Minimal API fixes for typecheck: - Remove references to dropped tables/columns - Replace status-based queries with completedAt checks - Stub deprecated dependency/status endpoints for Phase 2 Drizzle migration: 0008_plane-lift-schema.sql (custom, big-bang)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
|
||||
import { and, eq, gte, inArray, isNull, or } from "drizzle-orm";
|
||||
import { and, eq, gte, inArray, isNull, isNotNull, or } from "drizzle-orm";
|
||||
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
|
||||
export const analyticsRoutes = new Hono();
|
||||
@@ -30,7 +30,7 @@ analyticsRoutes.get("/productivity", async (c) => {
|
||||
isNull(tasks.deletedAt),
|
||||
));
|
||||
|
||||
const completedTasks = allTasks.filter(t => t.status === "done");
|
||||
const completedTasks = allTasks.filter(t => t.completedAt !== null);
|
||||
const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0;
|
||||
|
||||
return c.json({
|
||||
@@ -163,9 +163,9 @@ analyticsRoutes.get("/projects", async (c) => {
|
||||
|
||||
const projectIds = allProjects.map((p) => p.id);
|
||||
|
||||
// Count tasks per project (any status, including non-done) for the domain
|
||||
// Count tasks per project for the domain
|
||||
const taskRows = projectIds.length > 0
|
||||
? await db.select({ projectId: tasks.projectId, status: tasks.status })
|
||||
? await db.select({ projectId: tasks.projectId, completedAt: tasks.completedAt })
|
||||
.from(tasks)
|
||||
.where(and(
|
||||
isNull(tasks.deletedAt),
|
||||
@@ -178,7 +178,7 @@ analyticsRoutes.get("/projects", async (c) => {
|
||||
if (!t.projectId) continue;
|
||||
const entry = counts.get(t.projectId) ?? { totalTasks: 0, completedTasks: 0 };
|
||||
entry.totalTasks += 1;
|
||||
if (t.status === "done") entry.completedTasks += 1;
|
||||
if (t.completedAt) entry.completedTasks += 1;
|
||||
counts.set(t.projectId, entry);
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ analyticsRoutes.get("/cycle", async (c) => {
|
||||
await requireWorkspaceAccess(c, domainId);
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - range);
|
||||
const doneTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), eq(tasks.status, "done"), gte(tasks.completedAt, startDate), isNull(tasks.deletedAt)));
|
||||
const doneTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNotNull(tasks.completedAt), gte(tasks.completedAt, startDate), isNull(tasks.deletedAt)));
|
||||
const durations: number[] = [];
|
||||
for (const t of doneTasks) if (t.completedAt) durations.push((t.completedAt.getTime() - t.createdAt.getTime()) / (1000*60*60*24));
|
||||
durations.sort((a,b)=>a-b);
|
||||
@@ -319,7 +319,7 @@ analyticsRoutes.get("/daily", async (c) => {
|
||||
for (const t of domainTasks) {
|
||||
const createdKey = localDateKey(t.createdAt);
|
||||
createdByDay.set(createdKey, (createdByDay.get(createdKey) || 0) + 1);
|
||||
if (t.status === "done" && t.completedAt) {
|
||||
if (t.completedAt) {
|
||||
const completedKey = localDateKey(t.completedAt);
|
||||
completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, domains, notes, noteLinks, noteEntityLinks, tasks, taskDependencies, habits, projects, sections, tags as tagsTable } from "@project-e/db";
|
||||
import { db, domains, notes, tasks, habits, projects, sections, tags as tagsTable, links } from "@project-e/db";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
@@ -57,18 +57,12 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg
|
||||
for (const s of sectionRows) addNode(s.id, s.name, 'section');
|
||||
for (const t of tagRows) addNode(t.id, t.name, 'tag');
|
||||
|
||||
const noteIds = noteRows.map(n => n.id);
|
||||
if (noteIds.length > 0) {
|
||||
const linkRows = await db.select().from(noteLinks).where(inArray(noteLinks.sourceNoteId, noteIds));
|
||||
for (const l of linkRows) addEdge(l.sourceNoteId, l.targetNoteId, 'note_link');
|
||||
const entityLinkRows = await db.select().from(noteEntityLinks).where(inArray(noteEntityLinks.noteId, noteIds));
|
||||
for (const l of entityLinkRows) addEdge(l.noteId, l.entityId, 'note_' + l.entityType);
|
||||
}
|
||||
|
||||
const taskIds = taskRows.map(t => t.id);
|
||||
if (taskIds.length > 0) {
|
||||
const depRows = await db.select().from(taskDependencies).where(inArray(taskDependencies.taskId, taskIds));
|
||||
for (const d of depRows) addEdge(d.taskId, d.dependsOnTaskId, 'depends_on');
|
||||
// Read links from the canonical links table
|
||||
const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id)];
|
||||
if (allIds.length > 0) {
|
||||
const linkRows = await db.select().from(links)
|
||||
.where(inArray(links.sourceId, allIds));
|
||||
for (const l of linkRows) addEdge(l.sourceId, l.targetId, l.linkType);
|
||||
}
|
||||
|
||||
for (const t of taskRows) { if (t.projectId) addEdge(t.id, t.projectId, 'task_project'); addEdge(t.id, domainId, 'task_domain'); }
|
||||
@@ -80,20 +74,6 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// Resolve the owning domain for a graph edge source. `type` may be an edge type
|
||||
// (note_link / note_entity / task_dependency) or a source entity type (note / task).
|
||||
async function resolveEdgeWorkspaceId(sourceId: string, type: string): Promise<string | null> {
|
||||
if (type === "note_link" || type === "note_entity" || type === "note") {
|
||||
const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, sourceId)).limit(1);
|
||||
return row?.domainId ?? null;
|
||||
}
|
||||
if (type === "task_dependency" || type === "task") {
|
||||
const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, sourceId)).limit(1);
|
||||
return row?.domainId ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/graph/nodes — All nodes
|
||||
graphRoutes.get("/nodes", async (c) => {
|
||||
try {
|
||||
@@ -136,41 +116,34 @@ graphRoutes.get("/edges", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/graph/edges — Create a relationship (note link)
|
||||
// POST /api/graph/edges — Create a relationship via the links table
|
||||
graphRoutes.post("/edges", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const body = await c.req.json();
|
||||
const { sourceId, targetId, type } = z.object({
|
||||
const { sourceId, targetId, type, sourceType, targetType } = z.object({
|
||||
sourceId: z.string().uuid(),
|
||||
targetId: z.string().uuid(),
|
||||
type: z.string().default("note_link"),
|
||||
type: z.string().default("relates"),
|
||||
sourceType: z.string().default("note"),
|
||||
targetType: z.string().default("note"),
|
||||
}).parse(body);
|
||||
|
||||
// Verify ownership before mutating anything. Both endpoints of the edge
|
||||
// must belong to the caller's domain.
|
||||
const workspaceId = await resolveEdgeWorkspaceId(sourceId, type);
|
||||
const workspaceId = await resolveEdgeWorkspaceId(sourceId, sourceType);
|
||||
if (workspaceId) {
|
||||
await requireWorkspaceAccess(c, workspaceId);
|
||||
}
|
||||
const targetType = type === "note_link" ? "note" : (type === "note_entity" || type === "task_dependency") ? "task" : type;
|
||||
const targetWorkspaceId = await resolveEdgeWorkspaceId(targetId, targetType);
|
||||
if (targetWorkspaceId) {
|
||||
await requireWorkspaceAccess(c, targetWorkspaceId);
|
||||
}
|
||||
|
||||
if (type === "note_link") {
|
||||
await db.insert(noteLinks).values({ sourceNoteId: sourceId, targetNoteId: targetId });
|
||||
} else if (type === "note_entity") {
|
||||
await db.insert(noteEntityLinks).values({ noteId: sourceId, entityType: "task", entityId: targetId });
|
||||
} else if (type === "task_dependency") {
|
||||
await db.insert(taskDependencies).values({ taskId: sourceId, dependsOnTaskId: targetId });
|
||||
} else {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Unknown edge type: " + type } }, 400);
|
||||
}
|
||||
await db.insert(links).values({
|
||||
sourceType,
|
||||
sourceId,
|
||||
targetType,
|
||||
targetId,
|
||||
linkType: type as any,
|
||||
});
|
||||
|
||||
if (!workspaceId) {
|
||||
console.warn(`[graph] POST /edges: could not resolve workspace for source ${sourceId} (type ${type}); skipping activity`);
|
||||
console.warn(`[graph] POST /edges: could not resolve workspace for source ${sourceId} (type ${sourceType}); skipping activity`);
|
||||
} else {
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
@@ -195,39 +168,20 @@ graphRoutes.post("/edges", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/graph/edges/:id — Remove
|
||||
// DELETE /api/graph/edges/:id — Remove via links table
|
||||
graphRoutes.delete("/edges/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const [sourceId, targetId] = id.split("-");
|
||||
|
||||
// The type isn't known at delete time, so resolve from the source entity:
|
||||
// it's either a note or a task. Verify ownership before mutating anything.
|
||||
let workspaceId = await resolveEdgeWorkspaceId(sourceId, "note");
|
||||
if (!workspaceId) {
|
||||
workspaceId = await resolveEdgeWorkspaceId(sourceId, "task");
|
||||
}
|
||||
const workspaceId = await resolveEdgeWorkspaceId(sourceId, "note") || await resolveEdgeWorkspaceId(sourceId, "task");
|
||||
if (workspaceId) {
|
||||
await requireWorkspaceAccess(c, workspaceId);
|
||||
}
|
||||
|
||||
// Try deleting from note_links first
|
||||
const result = await db.delete(noteLinks)
|
||||
.where(and(eq(noteLinks.sourceNoteId, sourceId), eq(noteLinks.targetNoteId, targetId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
// Try note_entity_links (note → entity edges)
|
||||
const entityResult = await db.delete(noteEntityLinks)
|
||||
.where(and(eq(noteEntityLinks.noteId, sourceId), eq(noteEntityLinks.entityId, targetId)))
|
||||
.returning();
|
||||
if (entityResult.length === 0) {
|
||||
// Try task_dependencies
|
||||
await db.delete(taskDependencies)
|
||||
.where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId)));
|
||||
}
|
||||
}
|
||||
await db.delete(links)
|
||||
.where(and(eq(links.sourceId, sourceId), eq(links.targetId, targetId)));
|
||||
|
||||
if (!workspaceId) {
|
||||
console.warn(`[graph] DELETE /edges/${id}: could not resolve workspace for source ${sourceId}; skipping activity`);
|
||||
@@ -251,3 +205,15 @@ graphRoutes.delete("/edges/:id", async (c) => {
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete edge" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
async function resolveEdgeWorkspaceId(sourceId: string, type: string): Promise<string | null> {
|
||||
if (type === "note") {
|
||||
const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, sourceId)).limit(1);
|
||||
return row?.domainId ?? null;
|
||||
}
|
||||
if (type === "task") {
|
||||
const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, sourceId)).limit(1);
|
||||
return row?.domainId ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { createHash } from "node:crypto";
|
||||
import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db";
|
||||
import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db";
|
||||
import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
|
||||
@@ -95,7 +95,7 @@ const tools: ToolDefinition[] = [
|
||||
eq(tasks.domainId, params.domain_id as string),
|
||||
isNull(tasks.deletedAt),
|
||||
];
|
||||
if (params.status) conditions.push(eq(tasks.status, params.status as any));
|
||||
// TODO(phase-2): filter by state_group / state_id instead of old status
|
||||
if (params.priority) conditions.push(eq(tasks.priority, params.priority as any));
|
||||
if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string));
|
||||
if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`));
|
||||
@@ -119,7 +119,7 @@ const tools: ToolDefinition[] = [
|
||||
domain_id: { type: "string", description: "Workspace/domain ID" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
|
||||
status: { type: "string" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||
due_date: { type: "string" },
|
||||
project_id: { type: "string" },
|
||||
@@ -130,7 +130,6 @@ const tools: ToolDefinition[] = [
|
||||
const [task] = await db.insert(tasks).values({
|
||||
title: params.title as string,
|
||||
description: (params.description as string) ?? null,
|
||||
status: (params.status as any) ?? "todo",
|
||||
priority: (params.priority as any) ?? "medium",
|
||||
domainId: params.domain_id as string,
|
||||
projectId: (params.project_id as string) ?? null,
|
||||
@@ -142,7 +141,7 @@ const tools: ToolDefinition[] = [
|
||||
action: "created",
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
changes: { title: task.title, status: task.status },
|
||||
changes: { title: task.title },
|
||||
workspaceId: params.domain_id as string,
|
||||
});
|
||||
|
||||
@@ -158,7 +157,7 @@ const tools: ToolDefinition[] = [
|
||||
task_id: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
|
||||
status: { type: "string" },
|
||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
||||
due_date: { type: "string" },
|
||||
},
|
||||
@@ -172,7 +171,6 @@ const tools: ToolDefinition[] = [
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (params.title !== undefined) updateData.title = params.title;
|
||||
if (params.description !== undefined) updateData.description = params.description;
|
||||
if (params.status !== undefined) updateData.status = params.status;
|
||||
if (params.priority !== undefined) updateData.priority = params.priority;
|
||||
if (params.due_date !== undefined) updateData.dueDate = params.due_date ? new Date(params.due_date as string) : null;
|
||||
updateData.updatedAt = new Date();
|
||||
@@ -237,7 +235,7 @@ const tools: ToolDefinition[] = [
|
||||
await verifyDomainAccess(existing.domainId, auth.userId);
|
||||
|
||||
const [task] = await db.update(tasks)
|
||||
.set({ status: "done", completedAt: new Date(), updatedAt: new Date() })
|
||||
.set({ completedAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
|
||||
.returning();
|
||||
|
||||
@@ -566,7 +564,7 @@ const tools: ToolDefinition[] = [
|
||||
const results: Record<string, unknown[]> = {};
|
||||
|
||||
if (types.includes("tasks")) {
|
||||
results.tasks = await db.select({ id: tasks.id, title: tasks.title, status: tasks.status, priority: tasks.priority }).from(tasks)
|
||||
results.tasks = await db.select({ id: tasks.id, title: tasks.title, priority: tasks.priority }).from(tasks)
|
||||
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`))).limit(limit);
|
||||
}
|
||||
if (types.includes("notes")) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Note Link Service
|
||||
*
|
||||
* Handles wikilink resolution and note_links / note_entity_links management.
|
||||
* Handles wikilink resolution and links management.
|
||||
* On note save, parses content for [[wikilinks]], resolves each to a note_id or entity_id,
|
||||
* and diffs the existing links to produce idempotent deletes+inserts.
|
||||
*/
|
||||
|
||||
import { db, noteLinks, noteEntityLinks, notes, tasks, habits, projects, sections, tags as tagsTable } from "@project-e/db";
|
||||
import { and, eq, inArray, isNull, desc } from "drizzle-orm";
|
||||
import { db, links, notes, tasks, habits, projects, sections, tags as tagsTable } from "@project-e/db";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { extractLinkTargets } from "./wikilink-parser";
|
||||
|
||||
/**
|
||||
@@ -103,59 +103,39 @@ export async function syncNoteLinks(noteId: string, content: string, domainId: s
|
||||
}
|
||||
}
|
||||
|
||||
const noteToNoteLinks = resolvedTargets.filter(t => t.entityType === "note");
|
||||
const entityLinks = resolvedTargets.filter(t => t.entityType !== "note");
|
||||
// --- Sync all links from this note via the canonical links table ---
|
||||
const existingLinks = await db
|
||||
.select({ targetId: links.targetId, targetType: links.targetType })
|
||||
.from(links)
|
||||
.where(and(eq(links.sourceId, noteId), eq(links.sourceType, "note")));
|
||||
|
||||
// --- Sync note_links ---
|
||||
const existingNoteLinks = await db
|
||||
.select({ targetNoteId: noteLinks.targetNoteId })
|
||||
.from(noteLinks)
|
||||
.where(eq(noteLinks.sourceNoteId, noteId));
|
||||
const existingKeySet = new Set(existingLinks.map(l => `${l.targetType}:${l.targetId}`));
|
||||
const newKeySet = new Set(resolvedTargets.map(l => `${l.entityType}:${l.entityId}`));
|
||||
|
||||
const existingTargetIds = new Set(existingNoteLinks.map(l => l.targetNoteId));
|
||||
const newTargetIds = new Set(noteToNoteLinks.map(l => l.entityId));
|
||||
|
||||
const staleTargetIds = [...existingTargetIds].filter(id => !newTargetIds.has(id));
|
||||
if (staleTargetIds.length > 0) {
|
||||
// Delete stale links
|
||||
const staleLinks = existingLinks.filter(l => !newKeySet.has(`${l.targetType}:${l.targetId}`));
|
||||
if (staleLinks.length > 0) {
|
||||
const staleIds = staleLinks.map(l => l.targetId);
|
||||
await db
|
||||
.delete(noteLinks)
|
||||
.delete(links)
|
||||
.where(and(
|
||||
eq(noteLinks.sourceNoteId, noteId),
|
||||
inArray(noteLinks.targetNoteId, staleTargetIds),
|
||||
eq(links.sourceId, noteId),
|
||||
eq(links.sourceType, "note"),
|
||||
inArray(links.targetId, staleIds),
|
||||
));
|
||||
}
|
||||
|
||||
const missingTargetIds = [...newTargetIds].filter(id => !existingTargetIds.has(id));
|
||||
if (missingTargetIds.length > 0) {
|
||||
await db.insert(noteLinks).values(
|
||||
missingTargetIds.map(targetNoteId => ({ sourceNoteId: noteId, targetNoteId }))
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sync note_entity_links ---
|
||||
const existingEntityLinks = await db
|
||||
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
|
||||
.from(noteEntityLinks)
|
||||
.where(eq(noteEntityLinks.noteId, noteId));
|
||||
|
||||
const existingEntityKeySet = new Set(existingEntityLinks.map(l => `${l.entityType}:${l.entityId}`));
|
||||
const newEntityKeySet = new Set(entityLinks.map(l => `${l.entityType}:${l.entityId}`));
|
||||
|
||||
const staleEntityLinks = existingEntityLinks.filter(l => !newEntityKeySet.has(`${l.entityType}:${l.entityId}`));
|
||||
for (const link of staleEntityLinks) {
|
||||
await db
|
||||
.delete(noteEntityLinks)
|
||||
.where(and(
|
||||
eq(noteEntityLinks.noteId, noteId),
|
||||
eq(noteEntityLinks.entityType, link.entityType),
|
||||
eq(noteEntityLinks.entityId, link.entityId),
|
||||
));
|
||||
}
|
||||
|
||||
const missingEntityLinks = entityLinks.filter(l => !existingEntityKeySet.has(`${l.entityType}:${l.entityId}`));
|
||||
if (missingEntityLinks.length > 0) {
|
||||
await db.insert(noteEntityLinks).values(
|
||||
missingEntityLinks.map(l => ({ noteId, entityType: l.entityType, entityId: l.entityId }))
|
||||
// Insert missing links
|
||||
const missingTargets = resolvedTargets.filter(l => !existingKeySet.has(`${l.entityType}:${l.entityId}`));
|
||||
if (missingTargets.length > 0) {
|
||||
await db.insert(links).values(
|
||||
missingTargets.map(l => ({
|
||||
sourceType: "note",
|
||||
sourceId: noteId,
|
||||
targetType: l.entityType,
|
||||
targetId: l.entityId,
|
||||
linkType: "relates" as const,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -170,10 +150,12 @@ export async function getBacklinks(noteId: string): Promise<{ id: string; title:
|
||||
title: notes.title,
|
||||
content: notes.content,
|
||||
})
|
||||
.from(noteLinks)
|
||||
.innerJoin(notes, eq(noteLinks.sourceNoteId, notes.id))
|
||||
.from(links)
|
||||
.innerJoin(notes, eq(links.sourceId, notes.id))
|
||||
.where(and(
|
||||
eq(noteLinks.targetNoteId, noteId),
|
||||
eq(links.targetId, noteId),
|
||||
eq(links.sourceType, "note"),
|
||||
eq(links.targetType, "note"),
|
||||
isNull(notes.deletedAt),
|
||||
));
|
||||
|
||||
@@ -205,51 +187,52 @@ export async function getOutgoingLinks(noteId: string): Promise<{
|
||||
noteLinks: { id: string; title: string }[];
|
||||
entityLinks: { entityType: string; entityId: string; title: string | null }[];
|
||||
}> {
|
||||
const noteLinkRows = await db
|
||||
.select({ id: notes.id, title: notes.title })
|
||||
.from(noteLinks)
|
||||
.innerJoin(notes, eq(noteLinks.targetNoteId, notes.id))
|
||||
.where(and(
|
||||
eq(noteLinks.sourceNoteId, noteId),
|
||||
isNull(notes.deletedAt),
|
||||
));
|
||||
|
||||
const entityLinkRows = await db
|
||||
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
|
||||
.from(noteEntityLinks)
|
||||
.where(eq(noteEntityLinks.noteId, noteId));
|
||||
const outgoingLinks = await db
|
||||
.select({ targetId: links.targetId, targetType: links.targetType })
|
||||
.from(links)
|
||||
.where(and(eq(links.sourceId, noteId), eq(links.sourceType, "note")));
|
||||
|
||||
const noteLinkRows: { id: string; title: string }[] = [];
|
||||
const entityLinksWithTitles: { entityType: string; entityId: string; title: string | null }[] = [];
|
||||
for (const link of entityLinkRows) {
|
||||
let title: string | null = null;
|
||||
switch (link.entityType) {
|
||||
case "task": {
|
||||
const [t] = await db.select({ title: tasks.title }).from(tasks).where(eq(tasks.id, link.entityId)).limit(1);
|
||||
title = t?.title ?? null;
|
||||
break;
|
||||
}
|
||||
case "habit": {
|
||||
const [h] = await db.select({ name: habits.name }).from(habits).where(eq(habits.id, link.entityId)).limit(1);
|
||||
title = h?.name ?? null;
|
||||
break;
|
||||
}
|
||||
case "project": {
|
||||
const [p] = await db.select({ name: projects.name }).from(projects).where(eq(projects.id, link.entityId)).limit(1);
|
||||
title = p?.name ?? null;
|
||||
break;
|
||||
}
|
||||
case "section": {
|
||||
const [s] = await db.select({ name: sections.name }).from(sections).where(eq(sections.id, link.entityId)).limit(1);
|
||||
title = s?.name ?? null;
|
||||
break;
|
||||
}
|
||||
case "tag": {
|
||||
const [t] = await db.select({ name: tagsTable.name }).from(tagsTable).where(eq(tagsTable.id, link.entityId)).limit(1);
|
||||
title = t?.name ?? null;
|
||||
break;
|
||||
|
||||
for (const link of outgoingLinks) {
|
||||
if (link.targetType === "note") {
|
||||
const [note] = await db.select({ id: notes.id, title: notes.title })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, link.targetId), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
if (note) noteLinkRows.push({ id: note.id, title: note.title });
|
||||
} else {
|
||||
let title: string | null = null;
|
||||
switch (link.targetType) {
|
||||
case "task": {
|
||||
const [t] = await db.select({ title: tasks.title }).from(tasks).where(eq(tasks.id, link.targetId)).limit(1);
|
||||
title = t?.title ?? null;
|
||||
break;
|
||||
}
|
||||
case "habit": {
|
||||
const [h] = await db.select({ name: habits.name }).from(habits).where(eq(habits.id, link.targetId)).limit(1);
|
||||
title = h?.name ?? null;
|
||||
break;
|
||||
}
|
||||
case "project": {
|
||||
const [p] = await db.select({ name: projects.name }).from(projects).where(eq(projects.id, link.targetId)).limit(1);
|
||||
title = p?.name ?? null;
|
||||
break;
|
||||
}
|
||||
case "section": {
|
||||
const [s] = await db.select({ name: sections.name }).from(sections).where(eq(sections.id, link.targetId)).limit(1);
|
||||
title = s?.name ?? null;
|
||||
break;
|
||||
}
|
||||
case "tag": {
|
||||
const [t] = await db.select({ name: tagsTable.name }).from(tagsTable).where(eq(tagsTable.id, link.targetId)).limit(1);
|
||||
title = t?.name ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
entityLinksWithTitles.push({ entityType: link.targetType, entityId: link.targetId, title });
|
||||
}
|
||||
entityLinksWithTitles.push({ entityType: link.entityType, entityId: link.entityId, title });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db";
|
||||
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed, states } from "@project-e/db";
|
||||
import { and, asc, desc, eq, ilike, inArray, isNull, isNotNull, sql } from "drizzle-orm";
|
||||
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||
@@ -144,7 +144,7 @@ projectRoutes.get("/", async (c) => {
|
||||
|
||||
const [completedResult] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.projectId, projectId), eq(tasks.status, "done"), isNull(tasks.deletedAt)));
|
||||
.where(and(eq(tasks.projectId, projectId), isNotNull(tasks.completedAt), isNull(tasks.deletedAt)));
|
||||
|
||||
taskCountMap.set(projectId, {
|
||||
total: Number(totalResult?.count || 0),
|
||||
@@ -210,6 +210,24 @@ projectRoutes.post("/", async (c) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Seed 5 default workflow states for the new project (Decision 10)
|
||||
const defaultStates = [
|
||||
{ name: 'Backlog', color: '#94a3b8', group: 'backlog' as const, sortOrder: 0 },
|
||||
{ name: 'Todo', color: '#60a5fa', group: 'unstarted' as const, sortOrder: 1 },
|
||||
{ name: 'In Progress', color: '#facc15', group: 'started' as const, sortOrder: 2 },
|
||||
{ name: 'Done', color: '#4ade80', group: 'completed' as const, sortOrder: 3 },
|
||||
{ name: 'Cancelled', color: '#f87171', group: 'cancelled' as const, sortOrder: 4 },
|
||||
];
|
||||
await db.insert(states).values(
|
||||
defaultStates.map(s => ({
|
||||
name: s.name,
|
||||
color: s.color,
|
||||
group: s.group,
|
||||
sortOrder: s.sortOrder,
|
||||
projectId: project.id,
|
||||
}))
|
||||
);
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "created",
|
||||
@@ -277,7 +295,7 @@ projectRoutes.get("/:id", async (c) => {
|
||||
.where(eq(projectTags.projectId, id));
|
||||
|
||||
const totalTasks = projectTasks.length;
|
||||
const completedTasks = projectTasks.filter(t => t.status === "done").length;
|
||||
const completedTasks = projectTasks.filter(t => t.completedAt !== null).length;
|
||||
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
||||
|
||||
return c.json({
|
||||
|
||||
+29
-207
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
|
||||
import { db, tasks, 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";
|
||||
@@ -9,17 +9,18 @@ import { RRule } from "rrule";
|
||||
|
||||
export const taskRoutes = new Hono();
|
||||
|
||||
const taskStatusEnum = z.enum(["todo", "in_progress", "done", "cancelled"]);
|
||||
const taskPriorityEnum = z.enum(["low", "medium", "high", "urgent"]);
|
||||
|
||||
const createTaskSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
description: z.string().optional().nullable(),
|
||||
status: taskStatusEnum.optional().default("todo"),
|
||||
priority: taskPriorityEnum.optional().default("medium"),
|
||||
domain: z.string().min(1, "Domain is required"),
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
sectionId: z.string().uuid().optional().nullable(),
|
||||
stateId: z.string().uuid().optional().nullable(),
|
||||
moduleId: z.string().uuid().optional().nullable(),
|
||||
cycleId: z.string().uuid().optional().nullable(),
|
||||
parentId: z.string().uuid().optional().nullable(),
|
||||
dueDate: z.string().datetime().optional().nullable(),
|
||||
estimatedMinutes: z.number().int().positive().optional().nullable(),
|
||||
@@ -33,10 +34,12 @@ const createTaskSchema = z.object({
|
||||
const updateTaskSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
status: taskStatusEnum.optional(),
|
||||
priority: taskPriorityEnum.optional(),
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
sectionId: z.string().uuid().optional().nullable(),
|
||||
stateId: z.string().uuid().optional().nullable(),
|
||||
moduleId: z.string().uuid().optional().nullable(),
|
||||
cycleId: z.string().uuid().optional().nullable(),
|
||||
parentId: z.string().uuid().optional().nullable(),
|
||||
dueDate: z.string().datetime().optional().nullable(),
|
||||
estimatedMinutes: z.number().int().positive().optional().nullable(),
|
||||
@@ -86,7 +89,7 @@ taskRoutes.get("/", async (c) => {
|
||||
const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
|
||||
const filter = url.searchParams.get("filter") || undefined;
|
||||
const sort = url.searchParams.get("sort") || "-created";
|
||||
const status = url.searchParams.get("status");
|
||||
const stateId = url.searchParams.get("state_id");
|
||||
const priority = url.searchParams.get("priority");
|
||||
const tag = url.searchParams.get("tag");
|
||||
const search = url.searchParams.get("search");
|
||||
@@ -111,9 +114,9 @@ taskRoutes.get("/", async (c) => {
|
||||
isNull(tasks.deletedAt),
|
||||
];
|
||||
|
||||
if (status) {
|
||||
const statuses = status.split(",");
|
||||
conditions.push(inArray(tasks.status, statuses as any));
|
||||
if (stateId) {
|
||||
const stateIds = stateId.split(",");
|
||||
conditions.push(inArray(tasks.stateId, stateIds));
|
||||
}
|
||||
if (priority) {
|
||||
const priorities = priority.split(",");
|
||||
@@ -165,7 +168,6 @@ taskRoutes.get("/", async (c) => {
|
||||
created: tasks.createdAt,
|
||||
updated: tasks.updatedAt,
|
||||
title: tasks.title,
|
||||
status: tasks.status,
|
||||
priority: tasks.priority,
|
||||
order: tasks.order,
|
||||
due_date: tasks.dueDate,
|
||||
@@ -297,11 +299,13 @@ taskRoutes.post("/", async (c) => {
|
||||
const [task] = await db.insert(tasks).values({
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
domainId: data.domain,
|
||||
projectId: data.projectId ?? null,
|
||||
sectionId: data.sectionId ?? null,
|
||||
stateId: data.stateId ?? null,
|
||||
moduleId: data.moduleId ?? null,
|
||||
cycleId: data.cycleId ?? null,
|
||||
parentId: data.parentId ?? null,
|
||||
dueDate: data.dueDate ? new Date(data.dueDate) : null,
|
||||
estimatedMinutes: data.estimatedMinutes ?? null,
|
||||
@@ -334,7 +338,7 @@ taskRoutes.post("/", async (c) => {
|
||||
action: "created",
|
||||
entityType: "task",
|
||||
entityId: task.id,
|
||||
changes: { title: task.title, status: task.status, priority: task.priority },
|
||||
changes: { title: task.title, priority: task.priority },
|
||||
workspaceId: data.domain,
|
||||
});
|
||||
|
||||
@@ -458,25 +462,9 @@ taskRoutes.get("/:id", async (c) => {
|
||||
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
|
||||
.where(eq(taskTags.taskId, id));
|
||||
|
||||
// Fetch dependencies (tasks this task depends on)
|
||||
const depRows = await db.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
status: tasks.status,
|
||||
})
|
||||
.from(taskDependencies)
|
||||
.innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id))
|
||||
.where(and(eq(taskDependencies.taskId, id), isNull(tasks.deletedAt)));
|
||||
|
||||
// Fetch dependents (tasks that depend on this task)
|
||||
const dependentRows = await db.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
status: tasks.status,
|
||||
})
|
||||
.from(taskDependencies)
|
||||
.innerJoin(tasks, eq(taskDependencies.taskId, tasks.id))
|
||||
.where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt)));
|
||||
// Dependencies are now managed via the links table (Phase 2)
|
||||
const depRows: { id: string; title: string }[] = [];
|
||||
const dependentRows: { id: string; title: string }[] = [];
|
||||
|
||||
return c.json({
|
||||
...task,
|
||||
@@ -539,10 +527,12 @@ taskRoutes.patch("/:id", async (c) => {
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) updateValues.title = data.title;
|
||||
if (data.description !== undefined) updateValues.description = data.description;
|
||||
if (data.status !== undefined) updateValues.status = data.status;
|
||||
if (data.priority !== undefined) updateValues.priority = data.priority;
|
||||
if (data.projectId !== undefined) updateValues.projectId = data.projectId;
|
||||
if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId;
|
||||
if (data.stateId !== undefined) updateValues.stateId = data.stateId;
|
||||
if (data.moduleId !== undefined) updateValues.moduleId = data.moduleId;
|
||||
if (data.cycleId !== undefined) updateValues.cycleId = data.cycleId;
|
||||
if (data.parentId !== undefined) updateValues.parentId = data.parentId;
|
||||
if (data.dueDate !== undefined) updateValues.dueDate = data.dueDate ? new Date(data.dueDate) : null;
|
||||
if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes;
|
||||
@@ -561,11 +551,11 @@ taskRoutes.patch("/:id", async (c) => {
|
||||
action: "updated",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { ...data, previousStatus: existing.status },
|
||||
changes: { ...data, previousStateId: existing.stateId },
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } });
|
||||
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStateId: existing.stateId } });
|
||||
|
||||
if (data.recurrenceRule !== undefined) {
|
||||
await syncScheduledJob(id, data.recurrenceRule);
|
||||
@@ -728,187 +718,19 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/dependencies — Make this task depend on another task
|
||||
// POST /api/tasks/:id/dependencies — Deprecated: use links table instead (Phase 2)
|
||||
taskRoutes.post("/:id/dependencies", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const { dependsOnTaskId } = z.object({
|
||||
dependsOnTaskId: z.string().uuid("Invalid task id"),
|
||||
}).parse(body);
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (!task) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
// A task cannot depend on itself
|
||||
if (dependsOnTaskId === id) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot depend on itself" } }, 400);
|
||||
}
|
||||
|
||||
const [depTask] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, dependsOnTaskId), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (!depTask) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Dependency task not found" } }, 404);
|
||||
}
|
||||
if (depTask.domainId !== task.domainId) {
|
||||
return c.json({ error: { code: "FORBIDDEN", message: "Dependency task does not belong to this workspace" } }, 403);
|
||||
}
|
||||
|
||||
// Cycle guard: walk the dependency chain (X depends on Y, Y on Z, ...) from
|
||||
// dependsOnTaskId; reaching id means adding this edge would create a cycle.
|
||||
let currentId: string | null = dependsOnTaskId;
|
||||
const visited = new Set<string>([id]);
|
||||
while (currentId) {
|
||||
if (visited.has(currentId)) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Circular dependency detected" } }, 400);
|
||||
}
|
||||
visited.add(currentId);
|
||||
const [next] = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
|
||||
.from(taskDependencies)
|
||||
.where(eq(taskDependencies.taskId, currentId))
|
||||
.limit(1);
|
||||
currentId = next?.dependsOnTaskId ?? null;
|
||||
}
|
||||
|
||||
// Junction table has a composite PK — ignore duplicate edges
|
||||
await db.insert(taskDependencies).values({ taskId: id, dependsOnTaskId }).onConflictDoNothing();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "dependency_added",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { dependsOnTaskId },
|
||||
workspaceId: task.domainId,
|
||||
});
|
||||
|
||||
await enqueueWebhooks({ workspaceId: task.domainId, event: "task.updated", entityType: "task", entityId: id, data: { dependsOnTaskId } });
|
||||
|
||||
return c.json({ success: true }, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[tasks] POST /:id/dependencies error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add dependency" } }, 500);
|
||||
}
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id/dependencies/:depId — Remove a dependency
|
||||
// DELETE /api/tasks/:id/dependencies/:depId — Deprecated: use links table instead (Phase 2)
|
||||
taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const depId = c.req.param("depId");
|
||||
|
||||
const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (!task) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, task.domainId);
|
||||
|
||||
// Junction table has no deleted_at — hard delete is correct here
|
||||
await db.delete(taskDependencies).where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, depId)));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "dependency_removed",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { removedDependsOnTaskId: depId },
|
||||
workspaceId: task.domainId,
|
||||
});
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[tasks] DELETE /:id/dependencies/:depId error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove dependency" } }, 500);
|
||||
}
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/status — Change task status (Kanban drag)
|
||||
// POST /api/tasks/:id/status — Deprecated: use state_id instead (Phase 2)
|
||||
taskRoutes.post("/:id/status", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
if (!isUuid(id)) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const { status: newStatus } = z.object({
|
||||
status: taskStatusEnum,
|
||||
}).parse(body);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||
}
|
||||
|
||||
await requireWorkspaceAccess(c, existing.domainId);
|
||||
|
||||
const updateValues: Record<string, unknown> = {
|
||||
status: newStatus,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (newStatus === "done") {
|
||||
updateValues.completedAt = new Date();
|
||||
}
|
||||
|
||||
const [updated] = await db.update(tasks)
|
||||
.set(updateValues)
|
||||
.where(eq(tasks.id, id))
|
||||
.returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: newStatus === "done" ? "completed" : "updated",
|
||||
entityType: "task",
|
||||
entityId: id,
|
||||
changes: { previousStatus: existing.status, newStatus },
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[tasks] POST /:id/status error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update task status" } }, 500);
|
||||
}
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user