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:
2026-09-07 17:02:04 +00:00
parent 264f65955b
commit 639306a26f
11 changed files with 5064 additions and 448 deletions
+7 -7
View File
@@ -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);
}
+37 -71
View File
@@ -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;
}
+7 -9
View File
@@ -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")) {
+76 -93
View File
@@ -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 {
+22 -4
View File
@@ -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
View File
@@ -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)
-1
View File
@@ -316,7 +316,6 @@ async function handleRecurringSpawn(job: typeof jobs.$inferSelect): Promise<void
await db.insert(tasks).values({
title: originalTask.title,
description: originalTask.description,
status: 'todo',
priority: originalTask.priority,
domainId: originalTask.domainId,
projectId: originalTask.projectId,
+81
View File
@@ -0,0 +1,81 @@
-- Plane-Lift schema: big-bang break (DB is empty, no data migration)
-- Adds states, modules, cycles, links tables.
-- Drops note_links, note_entity_links, task_dependencies.
-- Drops tasks.status enum column, adds state_id/module_id/cycle_id FKs.
-- Drops the task_status enum type.
-- ── Drop old junction tables ────────────────────────────────────────────────────
DROP TABLE IF EXISTS "task_dependencies" CASCADE;--> statement-breakpoint
DROP TABLE IF EXISTS "note_links" CASCADE;--> statement-breakpoint
DROP TABLE IF EXISTS "note_entity_links" CASCADE;--> statement-breakpoint
-- ── Create new enums ───────────────────────────────────────────────────────────
CREATE TYPE "state_group" AS ENUM ('backlog', 'unstarted', 'started', 'completed', 'cancelled');--> statement-breakpoint
CREATE TYPE "module_status" AS ENUM ('planned', 'in_progress', 'completed', 'cancelled');--> statement-breakpoint
CREATE TYPE "link_type" AS ENUM ('relates', 'blocks', 'parent-child', 'created-from');--> statement-breakpoint
-- ── Create new tables ──────────────────────────────────────────────────────────
CREATE TABLE "states" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"color" text,
"group" "state_group" NOT NULL DEFAULT 'unstarted',
"project_id" uuid NOT NULL,
"sort_order" integer DEFAULT 0,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE INDEX "states_project_id_idx" ON "states" ("project_id");--> statement-breakpoint
CREATE INDEX "states_sort_order_idx" ON "states" ("project_id","sort_order");--> statement-breakpoint
CREATE TABLE "modules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"project_id" uuid NOT NULL,
"status" "module_status" NOT NULL DEFAULT 'planned',
"start_date" timestamp with time zone,
"target_date" timestamp with time zone,
"sort_order" integer DEFAULT 0,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE INDEX "modules_project_id_idx" ON "modules" ("project_id");--> statement-breakpoint
CREATE TABLE "cycles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"project_id" uuid NOT NULL,
"start_date" timestamp with time zone,
"end_date" timestamp with time zone,
"active" boolean DEFAULT false,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE INDEX "cycles_project_id_idx" ON "cycles" ("project_id");--> statement-breakpoint
CREATE TABLE "links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source_type" text NOT NULL,
"source_id" uuid NOT NULL,
"target_type" text NOT NULL,
"target_id" uuid NOT NULL,
"link_type" "link_type" NOT NULL,
"direction" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE INDEX "links_source_idx" ON "links" ("source_type","source_id");--> statement-breakpoint
CREATE INDEX "links_target_idx" ON "links" ("target_type","target_id");--> statement-breakpoint
-- ── Modify tasks table ─────────────────────────────────────────────────────────
ALTER TABLE "tasks" ADD COLUMN "state_id" uuid;--> statement-breakpoint
ALTER TABLE "tasks" ADD COLUMN "module_id" uuid;--> statement-breakpoint
ALTER TABLE "tasks" ADD COLUMN "cycle_id" uuid;--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_state_id_states_id_fk" FOREIGN KEY ("state_id") REFERENCES "states"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_module_id_modules_id_fk" FOREIGN KEY ("module_id") REFERENCES "modules"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_cycle_id_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "cycles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "tasks_state_id_idx" ON "tasks" ("state_id");--> statement-breakpoint
CREATE INDEX "tasks_module_id_idx" ON "tasks" ("module_id");--> statement-breakpoint
CREATE INDEX "tasks_cycle_id_idx" ON "tasks" ("cycle_id");--> statement-breakpoint
ALTER TABLE "tasks" DROP COLUMN "status";--> statement-breakpoint
DROP INDEX IF EXISTS "tasks_status_idx";--> statement-breakpoint
-- ── Drop old enum type ─────────────────────────────────────────────────────────
DROP TYPE IF EXISTS "task_status" CASCADE;
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"dialect": "postgresql",
"tag": "0000_outstanding_zuras",
"createdAt": "2025-01-01T00:00:00.000Z"
},
{
"idx": 1,
"version": "7",
"dialect": "postgresql",
"tag": "0001_warm_eternity",
"createdAt": "2025-01-01T00:01:00.000Z"
},
{
"idx": 2,
"version": "7",
"dialect": "postgresql",
"tag": "0002_steep_black_widow",
"createdAt": "2025-01-01T00:02:00.000Z"
},
{
"idx": 3,
"version": "7",
"dialect": "postgresql",
"tag": "0003_amazing_saracen",
"createdAt": "2025-01-01T00:03:00.000Z"
},
{
"idx": 4,
"version": "7",
"dialect": "postgresql",
"tag": "0004_add_owner_id_to_domains",
"createdAt": "2025-01-01T00:04:00.000Z"
},
{
"idx": 5,
"version": "7",
"dialect": "postgresql",
"tag": "0005_search_vector_trigger",
"createdAt": "2025-01-01T00:05:00.000Z"
},
{
"idx": 6,
"version": "7",
"dialect": "postgresql",
"tag": "0006_minor_doctor_octopus",
"createdAt": "2025-01-01T00:06:00.000Z"
},
{
"idx": 7,
"version": "7",
"dialect": "postgresql",
"tag": "0007_drop_canvas_reports",
"createdAt": "2025-01-01T00:07:00.000Z"
},
{
"idx": 8,
"version": "7",
"when": 1788800393149,
"tag": "0008_plane-lift-schema",
"breakpoints": true
}
]
}
+95 -56
View File
@@ -23,7 +23,6 @@ export const tsvector = customType<{ data: string }>({
// ── Enums ──────────────────────────────────────────────────────────────────────
export const taskStatusEnum = pgEnum('task_status', ['todo', 'in_progress', 'done', 'cancelled']);
export const taskPriorityEnum = pgEnum('task_priority', ['low', 'medium', 'high', 'urgent']);
export const habitFrequencyEnum = pgEnum('habit_frequency', ['daily', 'weekly', 'custom']);
export const habitDifficultyEnum = pgEnum('habit_difficulty', ['easy', 'medium', 'hard']);
@@ -32,6 +31,9 @@ export const sectionKindEnum = pgEnum('section_kind', ['section', 'milestone']);
export const sectionStatusEnum = pgEnum('section_status', ['planned', 'in_progress', 'complete']);
export const tagScopeEnum = pgEnum('tag_scope', ['global', 'tasks', 'habits', 'projects', 'notes']);
export const jobStatusEnum = pgEnum('job_status', ['pending', 'processing', 'completed', 'failed']);
export const stateGroupEnum = pgEnum('state_group', ['backlog', 'unstarted', 'started', 'completed', 'cancelled']);
export const moduleStatusEnum = pgEnum('module_status', ['planned', 'in_progress', 'completed', 'cancelled']);
export const linkTypeEnum = pgEnum('link_type', ['relates', 'blocks', 'parent-child', 'created-from']);
// ── Users ──────────────────────────────────────────────────────────────────────
@@ -143,6 +145,72 @@ export const sections = pgTable(
]
);
// ── States (per-project workflow states) ────────────────────────────────────────
export const states = pgTable(
'states',
{
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
color: text('color'),
group: stateGroupEnum('group').notNull().default('unstarted'),
projectId: uuid('project_id')
.notNull()
.references((): any => projects.id, { onDelete: 'cascade' }),
sortOrder: integer('sort_order').default(0),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('states_project_id_idx').on(table.projectId),
index('states_sort_order_idx').on(table.projectId, table.sortOrder),
]
);
// ── Modules (project-scoped planning buckets) ──────────────────────────────────
export const modules = pgTable(
'modules',
{
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
description: text('description'),
projectId: uuid('project_id')
.notNull()
.references((): any => projects.id, { onDelete: 'cascade' }),
status: moduleStatusEnum('status').notNull().default('planned'),
startDate: timestamp('start_date', { withTimezone: true }),
targetDate: timestamp('target_date', { withTimezone: true }),
sortOrder: integer('sort_order').default(0),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('modules_project_id_idx').on(table.projectId),
]
);
// ── Cycles (time-boxed sprints) ────────────────────────────────────────────────
export const cycles = pgTable(
'cycles',
{
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
projectId: uuid('project_id')
.notNull()
.references((): any => projects.id, { onDelete: 'cascade' }),
startDate: timestamp('start_date', { withTimezone: true }),
endDate: timestamp('end_date', { withTimezone: true }),
active: boolean('active').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('cycles_project_id_idx').on(table.projectId),
]
);
// ── Tasks ───────────────────────────────────────────────────────────────────────
export const tasks = pgTable(
@@ -151,13 +219,15 @@ export const tasks = pgTable(
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull(),
description: text('description'),
status: taskStatusEnum('status').notNull().default('todo'),
priority: taskPriorityEnum('priority').notNull().default('medium'),
domainId: uuid('domain_id')
.notNull()
.references((): any => domains.id, { onDelete: 'cascade' }),
projectId: uuid('project_id').references((): any => projects.id, { onDelete: 'set null' }),
sectionId: uuid('section_id').references((): any => sections.id, { onDelete: 'set null' }),
stateId: uuid('state_id').references((): any => states.id, { onDelete: 'set null' }),
moduleId: uuid('module_id').references((): any => modules.id, { onDelete: 'set null' }),
cycleId: uuid('cycle_id').references((): any => cycles.id, { onDelete: 'set null' }),
parentId: uuid('parent_id').references((): any => tasks.id, { onDelete: 'set null' }),
dueDate: timestamp('due_date', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
@@ -175,8 +245,10 @@ export const tasks = pgTable(
index('tasks_domain_id_idx').on(table.domainId),
index('tasks_project_id_idx').on(table.projectId),
index('tasks_section_id_idx').on(table.sectionId),
index('tasks_state_id_idx').on(table.stateId),
index('tasks_module_id_idx').on(table.moduleId),
index('tasks_cycle_id_idx').on(table.cycleId),
index('tasks_parent_id_idx').on(table.parentId),
index('tasks_status_idx').on(table.status),
index('tasks_priority_idx').on(table.priority),
index('tasks_due_date_idx').on(table.dueDate),
index('tasks_order_idx').on(table.order),
@@ -203,24 +275,6 @@ export const taskTags = pgTable(
]
);
// ── Task Dependencies (junction) ───────────────────────────────────────────────
export const taskDependencies = pgTable(
'task_dependencies',
{
taskId: uuid('task_id')
.notNull()
.references((): any => tasks.id, { onDelete: 'cascade' }),
dependsOnTaskId: uuid('depends_on_task_id')
.notNull()
.references((): any => tasks.id, { onDelete: 'cascade' }),
},
(table) => [
primaryKey({ columns: [table.taskId, table.dependsOnTaskId] }),
index('task_dependencies_depends_on_idx').on(table.dependsOnTaskId),
]
);
// ── Habits ──────────────────────────────────────────────────────────────────────
export const habits = pgTable(
@@ -321,41 +375,6 @@ export const notes = pgTable(
]
);
// ── Note Links (wikilinks / backlinks) ─────────────────────────────────────────
export const noteLinks = pgTable(
'note_links',
{
sourceNoteId: uuid('source_note_id')
.notNull()
.references((): any => notes.id, { onDelete: 'cascade' }),
targetNoteId: uuid('target_note_id')
.notNull()
.references((): any => notes.id, { onDelete: 'cascade' }),
},
(table) => [
primaryKey({ columns: [table.sourceNoteId, table.targetNoteId] }),
index('note_links_target_note_id_idx').on(table.targetNoteId),
]
);
// ── Note Entity Links (cross-entity linking) ────────────────────────────────────
export const noteEntityLinks = pgTable(
'note_entity_links',
{
noteId: uuid('note_id')
.notNull()
.references((): any => notes.id, { onDelete: 'cascade' }),
entityType: text('entity_type').notNull(),
entityId: uuid('entity_id').notNull(),
},
(table) => [
index('note_entity_links_entity_idx').on(table.entityType, table.entityId),
index('note_entity_links_note_id_idx').on(table.noteId),
]
);
// ── Note Tags (junction) ────────────────────────────────────────────────────────
export const noteTags = pgTable(
@@ -374,6 +393,26 @@ export const noteTags = pgTable(
]
);
// ── Links (canonical cross-entity mesh) ────────────────────────────────────────
export const links = pgTable(
'links',
{
id: uuid('id').defaultRandom().primaryKey(),
sourceType: text('source_type').notNull(),
sourceId: uuid('source_id').notNull(),
targetType: text('target_type').notNull(),
targetId: uuid('target_id').notNull(),
linkType: linkTypeEnum('link_type').notNull(),
direction: text('direction'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('links_source_idx').on(table.sourceType, table.sourceId),
index('links_target_idx').on(table.targetType, table.targetId),
]
);
// ── Project Tags (junction) ─────────────────────────────────────────────────────
export const projectTags = pgTable(