feat: add 5 PM features — custom statuses, Gantt, quick-add, automations, notifications

- Custom workflow statuses: per-project configurable status definitions
  replacing the fixed task_status enum. Each project defines its own
  workflow with drag-to-reorder, color coding, and category mapping.
- Gantt/timeline view: full project roadmap with task bars, dependency
  arrows, milestone diamonds, zoom levels (day/week/month), and drag
  to reschedule.
- Natural-language quick-add: NLP parser extracts dates, priorities,
  projects, labels, and recurrence from free text. Floating bar with
  'n' shortcut and live parsed preview.
- Automation rules: no-code trigger-action system per project. Triggers
  on status change, task creation, due date approaching. Actions set
  status/priority, add labels, create notifications.
- Notification center: in-app bell icon with unread badge, slide-out
  panel, real-time SSE updates, mark read/all read. Replaces raw
  activity feed dropdown.

Schema: adds status_definitions, automation_rules, notifications tables.
Migrations: 0007, 0008, 0009. 41 NLP parser tests pass.
This commit is contained in:
2026-08-19 10:54:29 +00:00
parent 1a620f16c1
commit b814a4788d
46 changed files with 4965 additions and 288 deletions
+6
View File
@@ -10,6 +10,8 @@ import { domainRoutes } from "./routes/domains";
import { taskRoutes } from "./routes/tasks";
import { habitRoutes } from "./routes/habits";
import { projectRoutes } from "./routes/projects";
import { statusRoutes } from "./routes/statuses";
import { automationRoutes } from "./routes/automations";
import { noteRoutes } from "./routes/notes";
import { searchRoutes } from "./routes/search";
import { calendarRoutes } from "./routes/calendar";
@@ -27,6 +29,7 @@ import { analyticsRoutes } from "./routes/analytics";
import { activityRoutes } from "./routes/activity";
import { importExportRoutes } from "./routes/import-export";
import { notificationRoutes } from "./routes/notifications";
import { timelineRoutes } from "./routes/timeline";
import { healthHandler } from "./routes/health";
const app = new Hono();
@@ -48,6 +51,8 @@ app.route("/api/domains", domainRoutes);
app.route("/api/tasks", taskRoutes);
app.route("/api/habits", habitRoutes);
app.route("/api/projects", projectRoutes);
app.route("/api/projects", statusRoutes);
app.route("/api/projects", automationRoutes);
app.route("/api/notes", noteRoutes);
app.route("/api/search", searchRoutes);
app.route("/api/calendar", calendarRoutes);
@@ -66,6 +71,7 @@ app.route("/api/activity", activityRoutes);
app.route("/api/notifications", notificationRoutes);
app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes);
app.route("/api", timelineRoutes);
app.route("/api/mcp", mcpRoutes);
// Persist uncaught server errors so the Settings → Error Log tab shows real
+291
View File
@@ -0,0 +1,291 @@
import { db, automationRules, tasks, taskTags, tags as tagsTable, statusDefinitions } from "@project-e/db";
import { and, eq, isNull } from "drizzle-orm";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { notifyWorkspaceOwner } from "./notify";
export const TRIGGER_TYPES = ["task_status_changed", "task_created", "due_date_approaching"] as const;
export const ACTION_TYPES = ["set_status", "set_priority", "add_label", "create_notification"] as const;
// Safety limit: cap the number of actions executed per trigger event so a
// misconfigured rule can never cascade into runaway writes.
const MAX_ACTIONS_PER_EVENT = 10;
export interface EvaluateAutomationsParams {
projectId: string | null | undefined;
triggerType: string;
/** The affected entity row (e.g. the task after its mutation). */
entity: Record<string, any>;
/** Diff of the mutation, e.g. { previousStatusId } for status changes. */
changes?: Record<string, any>;
/** Display name of the user who triggered the event. Defaults to "Automation". */
actor?: string;
}
type AutomationRuleRow = typeof automationRules.$inferSelect;
type StatusRow = typeof statusDefinitions.$inferSelect;
interface RuleContext {
projectId: string;
statusKey: string | null;
previousStatusKey: string | null;
priority: string | null;
labels: string[];
/** The affected entity row (e.g. the task after its mutation). */
entity: Record<string, any>;
statusById: Map<string, StatusRow>;
idByKey: Map<string, string>;
categoryById: Map<string, string>;
}
function matchesCondition(
condition: { field: string; op: string; value: any },
ctx: RuleContext
): boolean {
const value = condition.value;
switch (condition.field) {
case "project":
return ctx.projectId === value;
case "status": {
// `to`/`from` are status-change semantics; everything else compares the
// task's current status key.
if (condition.op === "to") return ctx.statusKey === value;
if (condition.op === "from") return ctx.previousStatusKey === value;
if (condition.op === "neq") return ctx.statusKey !== value;
return ctx.statusKey === value;
}
case "priority":
if (condition.op === "in") {
return Array.isArray(value) && value.includes(ctx.priority);
}
if (condition.op === "neq") return ctx.priority !== value;
return ctx.priority === value;
case "label":
if (condition.op === "not_has") return !ctx.labels.includes(value);
return ctx.labels.includes(value);
default:
return true;
}
}
/**
* Execute a single automation action immediately. Returns true when the action
* actually ran (i.e. it should count against the per-event limit), false when it
* was a no-op (e.g. status key no longer exists in the project).
*/
async function executeAction(
action: { type: string; params: Record<string, any> },
ctx: RuleContext,
actor: string
): Promise<boolean> {
const params = action.params ?? {};
const entity = ctx.entity;
switch (action.type) {
case "set_status": {
const statusId = ctx.idByKey.get(params.statusKey as string);
if (!statusId) return false;
const category = ctx.categoryById.get(statusId) ?? "todo";
await db.update(tasks)
.set({
statusId,
completedAt: category === "done" ? new Date() : null,
updatedAt: new Date(),
})
.where(and(eq(tasks.id, entity.id), isNull(tasks.deletedAt)));
await recordActivity({
actor,
action: "updated",
entityType: "task",
entityId: entity.id,
changes: {
previousStatusId: entity.statusId,
newStatusId: statusId,
viaAutomation: true,
automation: params.statusKey,
},
workspaceId: entity.domainId,
});
await enqueueWebhooks({
workspaceId: entity.domainId,
event: "task.updated",
entityType: "task",
entityId: entity.id,
data: { previousStatusId: entity.statusId, newStatusId: statusId, viaAutomation: true },
});
return true;
}
case "set_priority": {
const priority = params.priority as string;
await db.update(tasks)
.set({ priority: priority as any, updatedAt: new Date() })
.where(and(eq(tasks.id, entity.id), isNull(tasks.deletedAt)));
await recordActivity({
actor,
action: "updated",
entityType: "task",
entityId: entity.id,
changes: { previousPriority: entity.priority, newPriority: priority, viaAutomation: true },
workspaceId: entity.domainId,
});
await enqueueWebhooks({
workspaceId: entity.domainId,
event: "task.updated",
entityType: "task",
entityId: entity.id,
data: { previousPriority: entity.priority, newPriority: priority, viaAutomation: true },
});
return true;
}
case "add_label": {
const label = (params.label as string).trim();
if (!label) return false;
// Find-or-create a tag so rules can attach labels that don't exist yet.
let tagId: string | null = null;
const [existingTag] = await db.select({ id: tagsTable.id })
.from(tagsTable)
.where(eq(tagsTable.name, label))
.limit(1);
if (existingTag) {
tagId = existingTag.id;
} else {
const [created] = await db.insert(tagsTable).values({ name: label, scope: "tasks" }).returning({ id: tagsTable.id });
tagId = created.id;
}
await db.insert(taskTags).values({ taskId: entity.id, tagId }).onConflictDoNothing();
await recordActivity({
actor,
action: "tagged",
entityType: "task",
entityId: entity.id,
changes: { tagId, tagName: label, viaAutomation: true },
workspaceId: entity.domainId,
});
return true;
}
case "create_notification": {
const message = (params.message as string).trim();
if (!message) return false;
// Create a real in-app notification so it lands in the bell's unread
// badge and the notification sheet (single-user MVP: goes to the
// workspace owner). The activity row is kept for the audit trail.
await notifyWorkspaceOwner({
workspaceId: entity.domainId,
type: "automation",
title: message,
body: `Automation fired on "${entity.title ?? "task"}"`,
entityType: "task",
entityId: entity.id,
});
await recordActivity({
actor,
action: "notified",
entityType: "task",
entityId: entity.id,
changes: { message, viaAutomation: true },
workspaceId: entity.domainId,
});
return true;
}
default:
return false;
}
}
/**
* Find every active rule for a project whose trigger matches `triggerType`,
* check their conditions, and run the matching rules' actions immediately.
*
* Never throws: automation failures must not fail the user's request. All errors
* are logged. The total number of actions executed per event is capped at
* `MAX_ACTIONS_PER_EVENT`.
*/
export async function evaluateAutomations({
projectId,
triggerType,
entity,
changes = {},
actor = "Automation",
}: EvaluateAutomationsParams): Promise<void> {
if (!projectId || !entity?.id) return;
try {
const rules = await db.select()
.from(automationRules)
.where(and(
eq(automationRules.projectId, projectId),
eq(automationRules.active, true),
));
const matching = rules.filter((rule: AutomationRuleRow) => rule.trigger.type === triggerType);
if (matching.length === 0) return;
// Build the context maps once per event: status definitions for the project
// (key <-> id) and the task's current labels for condition matching.
const statuses = await db.select()
.from(statusDefinitions)
.where(eq(statusDefinitions.projectId, projectId));
const statusById = new Map(statuses.map((s) => [s.id, s]));
const idByKey = new Map<string, string>();
const categoryById = new Map<string, string>();
for (const status of statuses) {
idByKey.set(status.key, status.id);
categoryById.set(status.id, status.category);
}
const tagRows = await db.select({ name: tagsTable.name })
.from(taskTags)
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
.where(eq(taskTags.taskId, entity.id));
const statusKey = entity.statusId ? (statusById.get(entity.statusId)?.key ?? null) : null;
const previousStatusKey = changes.previousStatusId
? (statusById.get(changes.previousStatusId)?.key ?? null)
: null;
const ctx: RuleContext = {
projectId,
statusKey,
previousStatusKey,
priority: entity.priority ?? null,
labels: tagRows.map((r) => r.name),
entity,
statusById,
idByKey,
categoryById,
};
let executedActions = 0;
for (const rule of matching) {
if (executedActions >= MAX_ACTIONS_PER_EVENT) break;
const conditionsMatch = (rule.conditions ?? []).every((condition) =>
matchesCondition(condition, ctx)
);
if (!conditionsMatch) continue;
for (const action of rule.actions ?? []) {
if (executedActions >= MAX_ACTIONS_PER_EVENT) {
console.warn(`[automations] Hit safety limit of ${MAX_ACTIONS_PER_EVENT} actions for event ${triggerType} in project ${projectId}`);
break;
}
const ran = await executeAction(action, ctx, actor);
if (ran) executedActions += 1;
}
}
} catch (error) {
console.error(`[automations] evaluateAutomations failed for ${triggerType} in project ${projectId}:`, error);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { db, sql, notifications, domains } from "@project-e/db";
import { eq } from "drizzle-orm";
export type NotificationType =
| "mention"
| "status_change"
| "due_soon"
| "automation"
| "assignment";
export interface CreateNotificationParams {
userId: string;
workspaceId?: string | null;
type: NotificationType | string;
title: string;
body?: string | null;
entityType?: string | null;
entityId?: string | null;
}
/**
* Insert an in-app notification and fan it out over the realtime SSE stream so
* open clients refresh their bell count and list without polling. The event
* mirrors the pg_notify shape used by recordActivity: `{ type, action, id,
* workspace_id }` with `type: "notification"`.
*/
export async function createNotification(params: CreateNotificationParams): Promise<typeof notifications.$inferSelect> {
const { userId, workspaceId, type, title, body, entityType, entityId } = params;
const [notification] = await db.insert(notifications).values({
userId,
workspaceId: workspaceId ?? null,
type,
title,
body: body ?? null,
entityType: entityType ?? null,
entityId: entityId ?? null,
}).returning();
if (workspaceId) {
const payload = JSON.stringify({ type: "notification", action: "created", id: notification.id, workspace_id: workspaceId });
await sql`SELECT pg_notify('project_e_events', ${payload}::text)`;
}
return notification;
}
/**
* MVP convenience for the single-user app: resolve the workspace owner and send
* them the notification. Returns null when the workspace has no owner, so
* callers can rely on this never throwing for missing ownership.
*/
export async function notifyWorkspaceOwner(params: Omit<CreateNotificationParams, "userId">): Promise<typeof notifications.$inferSelect | null> {
const { workspaceId, ...rest } = params;
if (!workspaceId) return null;
const [domain] = await db
.select({ ownerId: domains.ownerId })
.from(domains)
.where(eq(domains.id, workspaceId))
.limit(1);
if (!domain?.ownerId) return null;
return createNotification({ ...rest, workspaceId, userId: domain.ownerId });
}
+14 -8
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 { db, tasks, habits, habitCompletions, projects, statusDefinitions } from "@project-e/db";
import { and, eq, gte, getTableColumns, inArray, isNull, or } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
export const analyticsRoutes = new Hono();
@@ -22,15 +22,18 @@ analyticsRoutes.get("/productivity", async (c) => {
const startDate = new Date();
startDate.setDate(startDate.getDate() - range);
const allTasks = await db.select()
const taskColumns = getTableColumns(tasks);
const allTasks = await db.select({ ...taskColumns, statusCategory: statusDefinitions.category })
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(
eq(tasks.domainId, domainId),
gte(tasks.createdAt, startDate),
isNull(tasks.deletedAt),
));
const completedTasks = allTasks.filter(t => t.status === "done");
// "Done" is a status category; a status marked category='done' completes a task.
const completedTasks = allTasks.filter(t => t.statusCategory === "done");
const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0;
return c.json({
@@ -127,8 +130,9 @@ analyticsRoutes.get("/projects", async (c) => {
// Count tasks per project (any status, including non-done) for the domain
const taskRows = projectIds.length > 0
? await db.select({ projectId: tasks.projectId, status: tasks.status })
? await db.select({ projectId: tasks.projectId, statusCategory: statusDefinitions.category })
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(
isNull(tasks.deletedAt),
inArray(tasks.projectId, projectIds),
@@ -140,7 +144,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.statusCategory === "done") entry.completedTasks += 1;
counts.set(t.projectId, entry);
}
@@ -191,8 +195,10 @@ analyticsRoutes.get("/daily", async (c) => {
firstDay.setDate(firstDay.getDate() - (range - 1));
firstDay.setHours(0, 0, 0, 0);
const domainTasks = await db.select()
const dailyTaskColumns = getTableColumns(tasks);
const domainTasks = await db.select({ ...dailyTaskColumns, statusCategory: statusDefinitions.category })
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(
eq(tasks.domainId, domainId),
isNull(tasks.deletedAt),
@@ -216,7 +222,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.statusCategory === "done" && t.completedAt) {
const completedKey = localDateKey(t.completedAt);
completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1);
}
+309
View File
@@ -0,0 +1,309 @@
import { Hono } from "hono";
import { db, automationRules, projects } from "@project-e/db";
import { and, asc, eq, isNull } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod";
export const automationRoutes = new Hono();
const TRIGGER_TYPES = ["task_status_changed", "task_created", "due_date_approaching"] as const;
const ACTION_TYPES = ["set_status", "set_priority", "add_label", "create_notification"] as const;
const PRIORITIES = ["low", "medium", "high", "urgent"] as const;
const triggerSchema = z.object({
type: z.enum(TRIGGER_TYPES),
params: z.record(z.string(), z.unknown()).optional(),
});
const conditionSchema = z.object({
field: z.enum(["project", "status", "priority", "label"]),
op: z.string().min(1),
value: z.unknown(),
});
const actionSchema = z.object({
type: z.enum(ACTION_TYPES),
params: z.record(z.string(), z.unknown()).optional(),
});
const createAutomationSchema = z.object({
name: z.string().min(1, "Name is required").max(120),
active: z.boolean().optional(),
trigger: triggerSchema,
conditions: z.array(conditionSchema).optional(),
actions: z.array(actionSchema).min(1, "At least one action is required"),
});
const updateAutomationSchema = createAutomationSchema.partial();
// Validate action params against what the evaluation engine expects, so a bad
// rule surfaces at save time instead of silently doing nothing at run time.
function validateActionParams(actions: { type: string; params?: Record<string, unknown> }[]): string | null {
for (const action of actions) {
const params = action.params ?? {};
switch (action.type) {
case "set_status":
if (!params.statusKey) return "set_status action requires a statusKey param";
break;
case "set_priority":
if (!params.priority || !PRIORITIES.includes(params.priority as any)) {
return "set_priority action requires a priority param (low, medium, high or urgent)";
}
break;
case "add_label":
if (!params.label || typeof params.label !== "string" || params.label.trim() === "") {
return "add_label action requires a label param";
}
break;
case "create_notification":
if (!params.message || typeof params.message !== "string" || params.message.trim() === "") {
return "create_notification action requires a message param";
}
break;
}
}
return null;
}
async function getProject(c: any, projectId: string) {
const [project] = await db
.select({ id: projects.id, name: projects.name, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
throw new AuthError("Project not found", 404, "NOT_FOUND");
}
await requireWorkspaceAccess(c, project.domainId);
return project;
}
// GET /api/projects/:projectId/automations — List rules for a project
automationRoutes.get("/:projectId/automations", async (c) => {
try {
await requireAuth(c);
const projectId = c.req.param("projectId");
if (!isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
await getProject(c, projectId);
const items = await db.select()
.from(automationRules)
.where(eq(automationRules.projectId, projectId))
.orderBy(asc(automationRules.createdAt));
return c.json({ items, totalItems: items.length });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[automations] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list automation rules" } }, 500);
}
});
// POST /api/projects/:projectId/automations — Create a rule
automationRoutes.post("/:projectId/automations", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
if (!isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = createAutomationSchema.parse(body);
const project = await getProject(c, projectId);
const actionError = validateActionParams(data.actions);
if (actionError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: actionError } }, 400);
}
const [rule] = await db.insert(automationRules).values({
projectId,
name: data.name,
active: data.active ?? true,
trigger: data.trigger,
conditions: (data.conditions ?? []) as any,
actions: data.actions as any,
}).returning();
await recordActivity({
actor: user.name,
action: "created",
entityType: "automation_rule",
entityId: rule.id,
changes: { name: rule.name, trigger: rule.trigger.type, projectId, projectName: project.name },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.created", entityType: "automation_rule", entityId: rule.id, data: { name: rule.name } });
return c.json(rule, 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("[automations] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create automation rule" } }, 500);
}
});
// PATCH /api/projects/:projectId/automations/:id — Update a rule
automationRoutes.patch("/:projectId/automations/:id", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
const id = c.req.param("id");
if (!isUuid(projectId) || !isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateAutomationSchema.parse(body);
const project = await getProject(c, projectId);
const [existing] = await db.select()
.from(automationRules)
.where(and(eq(automationRules.id, id), eq(automationRules.projectId, projectId)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Automation rule not found" } }, 404);
}
if (data.actions !== undefined) {
const actionError = validateActionParams(data.actions);
if (actionError) {
return c.json({ error: { code: "VALIDATION_ERROR", message: actionError } }, 400);
}
}
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
if (data.active !== undefined) updateValues.active = data.active;
if (data.trigger !== undefined) updateValues.trigger = data.trigger;
if (data.conditions !== undefined) updateValues.conditions = data.conditions;
if (data.actions !== undefined) updateValues.actions = data.actions;
updateValues.updatedAt = new Date();
const [updated] = await db.update(automationRules)
.set(updateValues)
.where(eq(automationRules.id, id))
.returning();
await recordActivity({
actor: user.name,
action: "updated",
entityType: "automation_rule",
entityId: id,
changes: { name: updated.name, projectId, previousName: existing.name },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.updated", entityType: "automation_rule", entityId: id, data: { name: updated.name } });
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("[automations] PATCH error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update automation rule" } }, 500);
}
});
// DELETE /api/projects/:projectId/automations/:id — Delete a rule
automationRoutes.delete("/:projectId/automations/:id", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
const id = c.req.param("id");
if (!isUuid(projectId) || !isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const project = await getProject(c, projectId);
const [existing] = await db.select()
.from(automationRules)
.where(and(eq(automationRules.id, id), eq(automationRules.projectId, projectId)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Automation rule not found" } }, 404);
}
// Rules are configuration, not user data — hard delete is correct.
await db.delete(automationRules).where(eq(automationRules.id, id));
await recordActivity({
actor: user.name,
action: "deleted",
entityType: "automation_rule",
entityId: id,
changes: { name: existing.name, projectId },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.deleted", entityType: "automation_rule", entityId: id, data: { name: existing.name } });
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("[automations] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete automation rule" } }, 500);
}
});
// POST /api/projects/:projectId/automations/:id/toggle — Flip active/inactive
automationRoutes.post("/:projectId/automations/:id/toggle", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
const id = c.req.param("id");
if (!isUuid(projectId) || !isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const project = await getProject(c, projectId);
const [existing] = await db.select()
.from(automationRules)
.where(and(eq(automationRules.id, id), eq(automationRules.projectId, projectId)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Automation rule not found" } }, 404);
}
const [updated] = await db.update(automationRules)
.set({ active: !existing.active, updatedAt: new Date() })
.where(eq(automationRules.id, id))
.returning();
await recordActivity({
actor: user.name,
action: updated.active ? "enabled" : "disabled",
entityType: "automation_rule",
entityId: id,
changes: { active: updated.active, projectId },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "automation.toggled", entityType: "automation_rule", entityId: id, data: { active: updated.active } });
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[automations] POST /toggle error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to toggle automation rule" } }, 500);
}
});
+18
View File
@@ -14,6 +14,7 @@ import { and, asc, eq, inArray, isNull } from "drizzle-orm";
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { notifyWorkspaceOwner } from "../lib/notify";
import { z } from "zod";
export const commentRoutes = new Hono();
@@ -150,6 +151,23 @@ commentRoutes.post("/", async (c) => {
workspaceId: domainId,
});
// Mention notification: any @mention in the comment notifies the workspace
// owner (single-user MVP — there is no other recipient to resolve).
if (/\B@[a-zA-Z0-9_.-]+/.test(data.content)) {
try {
await notifyWorkspaceOwner({
workspaceId: domainId,
type: "mention",
title: "You were mentioned",
body: `${user.name} mentioned you in a comment on this ${data.entityType.replace(/_/g, " ")}`,
entityType: data.entityType,
entityId: data.entityId,
});
} catch (notifyError) {
console.error(`[comments] Failed to create mention notification for ${data.entityType}:${data.entityId}:`, notifyError);
}
}
return c.json(newComment, 201);
} catch (error) {
if (error instanceof AuthError) {
+67 -11
View File
@@ -1,7 +1,7 @@
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 { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm";
import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries, statusDefinitions } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, or } from "drizzle-orm";
import { recordActivity } from "../middleware/activity";
export const mcpRoutes = new Hono();
@@ -81,7 +81,8 @@ const tools: ToolDefinition[] = [
type: "object",
properties: {
domain_id: { type: "string", description: "Workspace/domain ID" },
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
status: { type: "string", description: "Filter by status category (todo, in_progress, done, cancelled)" },
status_id: { type: "string", description: "Filter by a specific status definition ID" },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
project_id: { type: "string" },
search: { type: "string" },
@@ -95,7 +96,14 @@ 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));
if (params.status_id) conditions.push(eq(tasks.statusId, params.status_id as string));
if (params.status) {
// Category filter — match every status definition in that category.
const matching = await db.select({ id: statusDefinitions.id })
.from(statusDefinitions)
.where(eq(statusDefinitions.category, params.status as any));
conditions.push(inArray(tasks.statusId, matching.map((s) => s.id)));
}
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 +127,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_id: { type: "string", description: "Status definition ID (defaults to the project's default status)" },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
due_date: { type: "string" },
project_id: { type: "string" },
@@ -127,10 +135,33 @@ const tools: ToolDefinition[] = [
required: ["domain_id", "title"],
},
handler: async (params, auth) => {
// Resolve status: explicit status_id, else the project's default status.
let statusId = (params.status_id as string) ?? null;
let completedAt = null;
if (statusId) {
const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId, category: statusDefinitions.category })
.from(statusDefinitions)
.where(eq(statusDefinitions.id, statusId))
.limit(1);
if (!status) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status not found");
if (!params.project_id || status.projectId !== params.project_id) {
throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status does not belong to the selected project");
}
completedAt = status.category === "done" ? new Date() : null;
} else if (params.project_id) {
const [defaultStatus] = await db.select({ id: statusDefinitions.id, category: statusDefinitions.category })
.from(statusDefinitions)
.where(and(eq(statusDefinitions.projectId, params.project_id as string), eq(statusDefinitions.isDefault, true)))
.limit(1);
statusId = defaultStatus?.id ?? null;
completedAt = defaultStatus?.category === "done" ? new Date() : null;
}
const [task] = await db.insert(tasks).values({
title: params.title as string,
description: (params.description as string) ?? null,
status: (params.status as any) ?? "todo",
statusId,
completedAt,
priority: (params.priority as any) ?? "medium",
domainId: params.domain_id as string,
projectId: (params.project_id as string) ?? null,
@@ -142,7 +173,7 @@ const tools: ToolDefinition[] = [
action: "created",
entityType: "task",
entityId: task.id,
changes: { title: task.title, status: task.status },
changes: { title: task.title, statusId: task.statusId },
workspaceId: params.domain_id as string,
});
@@ -158,7 +189,7 @@ const tools: ToolDefinition[] = [
task_id: { type: "string" },
title: { type: "string" },
description: { type: "string" },
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
status_id: { type: "string", description: "Status definition ID (must belong to the task's project)" },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
due_date: { type: "string" },
},
@@ -172,7 +203,19 @@ 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.status_id !== undefined) {
const statusId = params.status_id as string;
const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId, category: statusDefinitions.category })
.from(statusDefinitions)
.where(eq(statusDefinitions.id, statusId))
.limit(1);
if (!status) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status not found");
if (!existing.projectId || status.projectId !== existing.projectId) {
throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Status does not belong to the task's project");
}
updateData.statusId = statusId;
updateData.completedAt = status.category === "done" ? new Date() : null;
}
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();
@@ -236,8 +279,21 @@ const tools: ToolDefinition[] = [
if (!existing) throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Task not found");
await verifyDomainAccess(existing.domainId, auth.userId);
// Complete = move to the project's first "done"-category status.
const [doneStatus] = await db.select({ id: statusDefinitions.id })
.from(statusDefinitions)
.where(and(
eq(statusDefinitions.projectId, existing.projectId ?? ""),
eq(statusDefinitions.category, "done"),
))
.orderBy(asc(statusDefinitions.sortOrder))
.limit(1);
if (!doneStatus) {
throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, "Project has no done-category status");
}
const [task] = await db.update(tasks)
.set({ status: "done", completedAt: new Date(), updatedAt: new Date() })
.set({ statusId: doneStatus.id, completedAt: new Date(), updatedAt: new Date() })
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
.returning();
@@ -566,7 +622,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, statusId: tasks.statusId, priority: tasks.priority }).from(tasks)
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`))).limit(limit);
}
if (types.includes("notes")) {
+154 -28
View File
@@ -1,44 +1,170 @@
import { Hono } from "hono";
import { db, activityFeed } from "@project-e/db";
import { and, desc, eq, gte, ne, sql } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
import { db, notifications } from "@project-e/db";
import { and, count, desc, eq, isNull } from "drizzle-orm";
import { requireAuth, createErrorResponse, AuthError, isUuid } from "../middleware/auth";
export const notificationRoutes = new Hono();
// The bell in the topbar shows a badge for activity_feed events from the last
// 7 days. There is no read/unread state yet, so "count" doubles as the unread
// badge. graph_edge rows are workspace-internal graph plumbing, not user-facing
// activity, so they are excluded from both the count and the feed.
const NOTIFICATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
// Notifications are per-user rows, so every route is scoped to the current
// user via requireAuth (not requireWorkspaceAccess). The optional
// workspace_id filter is safe against IDOR because the WHERE clause always
// pins notifications.userId to the authenticated user.
// GET /api/notifications?workspace_id=&limit= — Recent activity for a workspace.
const PAGE_SIZE_DEFAULT = 30;
// GET /api/notifications — List the current user's notifications, newest
// first. Query params: workspace_id (filter), unread=true (unread only),
// limit/offset or page/perPage for pagination.
notificationRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
let workspaceId = c.req.query("workspace_id");
if (!workspaceId) {
const active = await resolveActiveDomain(user);
workspaceId = active.id;
}
await requireWorkspaceAccess(c, workspaceId);
const workspaceId = c.req.query("workspace_id");
const unreadOnly = c.req.query("unread") === "true";
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "20", 10) || 20, 1), 100);
const since = new Date(Date.now() - NOTIFICATION_WINDOW_MS);
const conditions = [
eq(activityFeed.workspaceId, workspaceId),
gte(activityFeed.createdAt, since),
ne(activityFeed.entityType, "graph_edge"),
];
const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "0", 10) || 0, 1), 100);
const page = Math.max(1, parseInt(c.req.query("page") || "1", 10) || 1);
const perPage = Math.min(Math.max(parseInt(c.req.query("perPage") || "0", 10) || 0, 1), 100);
const effectiveLimit = limit || perPage || PAGE_SIZE_DEFAULT;
const offset = Math.max(0, parseInt(c.req.query("offset") || "0", 10) || 0) || (page - 1) * effectiveLimit;
const [items, countResult] = await Promise.all([
db.select().from(activityFeed).where(and(...conditions)).orderBy(desc(activityFeed.createdAt)).limit(limit),
db.select({ count: sql<number>`count(*)` }).from(activityFeed).where(and(...conditions)),
const conditions: any[] = [eq(notifications.userId, user.id), isNull(notifications.deletedAt)];
if (workspaceId) conditions.push(eq(notifications.workspaceId, workspaceId));
if (unreadOnly) conditions.push(isNull(notifications.readAt));
const [items, totalResult, unreadResult] = await Promise.all([
db.select().from(notifications)
.where(and(...conditions))
.orderBy(desc(notifications.createdAt))
.limit(effectiveLimit)
.offset(offset),
db.select({ value: count() }).from(notifications).where(and(...conditions)),
db.select({ value: count() }).from(notifications)
.where(and(...conditions, isNull(notifications.readAt))),
]);
return c.json({ items, count: Number(countResult[0]?.count || 0) });
const totalItems = Number(totalResult[0]?.value ?? 0);
return c.json({
items,
totalItems,
unreadCount: Number(unreadResult[0]?.value ?? 0),
page,
perPage: effectiveLimit,
limit: effectiveLimit,
offset,
});
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[notifications] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get notifications" } }, 500);
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to list notifications"), 500);
}
});
// GET /api/notifications/count — Unread count for the bell badge.
notificationRoutes.get("/count", async (c) => {
try {
const user = await requireAuth(c);
const workspaceId = c.req.query("workspace_id");
const conditions: any[] = [eq(notifications.userId, user.id), isNull(notifications.readAt), isNull(notifications.deletedAt)];
if (workspaceId) conditions.push(eq(notifications.workspaceId, workspaceId));
const [result] = await db.select({ value: count() }).from(notifications).where(and(...conditions));
return c.json({ count: Number(result?.value ?? 0) });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[notifications] GET /count error:", error);
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to get unread count"), 500);
}
});
// PATCH /api/notifications/:id — Mark a notification as read.
notificationRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404);
}
const [existing] = await db.select({ id: notifications.id, userId: notifications.userId, deletedAt: notifications.deletedAt })
.from(notifications)
.where(and(eq(notifications.id, id), isNull(notifications.deletedAt)))
.limit(1);
if (!existing || existing.userId !== user.id) {
return c.json(createErrorResponse("NOT_FOUND", "Notification not found"), 404);
}
await db.update(notifications)
.set({ readAt: new Date() })
.where(eq(notifications.id, id));
return c.json({ success: true, id });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[notifications] PATCH error:", error);
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to update notification"), 500);
}
});
// POST /api/notifications/read-all — Mark every notification in the workspace
// (or all notifications when no workspace_id is given) as read.
notificationRoutes.post("/read-all", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json().catch(() => ({}));
const workspaceId = (body as { workspace_id?: string }).workspace_id ?? c.req.query("workspace_id");
const conditions: any[] = [eq(notifications.userId, user.id), isNull(notifications.readAt), isNull(notifications.deletedAt)];
if (workspaceId) conditions.push(eq(notifications.workspaceId, workspaceId));
const result = await db.update(notifications)
.set({ readAt: new Date() })
.where(and(...conditions))
.returning({ id: notifications.id });
return c.json({ success: true, updated: result.length });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[notifications] POST /read-all error:", error);
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to mark all notifications as read"), 500);
}
});
// DELETE /api/notifications/:id — Soft-delete a notification.
notificationRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json(createErrorResponse("NOT_FOUND", "Resource not found"), 404);
}
const [existing] = await db.select({ id: notifications.id, userId: notifications.userId, deletedAt: notifications.deletedAt })
.from(notifications)
.where(and(eq(notifications.id, id), isNull(notifications.deletedAt)))
.limit(1);
if (!existing || existing.userId !== user.id) {
return c.json(createErrorResponse("NOT_FOUND", "Notification not found"), 404);
}
await db.update(notifications)
.set({ deletedAt: new Date() })
.where(eq(notifications.id, id));
return c.json({ success: true, id });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[notifications] DELETE error:", error);
return c.json(createErrorResponse("INTERNAL_ERROR", "Failed to delete notification"), 500);
}
});
+53 -7
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db";
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed, statusDefinitions } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
@@ -10,6 +10,16 @@ export const projectRoutes = new Hono();
const projectStatusEnum = z.enum(["active", "paused", "completed", "archived"]);
// Default workflow statuses seeded for every new project. `isDefault` marks the
// status new tasks get (the "todo" start state). Categories map to progress and
// board-column semantics.
const DEFAULT_STATUSES = [
{ key: "todo", label: "Todo", category: "todo", color: "#94a3b8", sortOrder: 0, isDefault: true },
{ key: "in_progress", label: "In Progress", category: "in_progress", color: "#3b82f6", sortOrder: 1, isDefault: false },
{ key: "done", label: "Done", category: "done", color: "#22c55e", sortOrder: 2, isDefault: false },
{ key: "cancelled", label: "Cancelled", category: "cancelled", color: "#ef4444", sortOrder: 3, isDefault: false },
] as const;
const createProjectSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional().nullable(),
@@ -113,9 +123,10 @@ projectRoutes.get("/", async (c) => {
const totalItems = Number(countResult[0]?.count || 0);
// Fetch task counts and tags for all projects
// Fetch task counts, status definitions and tags for all projects
let projectTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
let taskCountMap = new Map<string, { total: number; completed: number }>();
let statusMap = new Map<string, typeof statusDefinitions.$inferSelect[]>();
if (items.length > 0) {
const projectIds = items.map(p => p.id);
@@ -136,15 +147,32 @@ projectRoutes.get("/", async (c) => {
projectTagMap.get(row.projectId)!.push({ id: row.id, name: row.name, color: row.color });
}
// Status definitions
const statusRows = await db.select()
.from(statusDefinitions)
.where(inArray(statusDefinitions.projectId, projectIds))
.orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt));
for (const row of statusRows) {
if (!statusMap.has(row.projectId)) statusMap.set(row.projectId, []);
statusMap.get(row.projectId)!.push(row);
}
// Task counts
for (const projectId of projectIds) {
const [totalResult] = await db.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt)));
// "Done" is a status category, not a fixed status — a project's status
// workflow can mark any status with category='done' as completing.
const [completedResult] = await db.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(and(eq(tasks.projectId, projectId), eq(tasks.status, "done"), isNull(tasks.deletedAt)));
.innerJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(
eq(tasks.projectId, projectId),
eq(statusDefinitions.category, "done"),
isNull(tasks.deletedAt),
));
taskCountMap.set(projectId, {
total: Number(totalResult?.count || 0),
@@ -158,6 +186,7 @@ projectRoutes.get("/", async (c) => {
return {
...p,
tags: projectTagMap.get(p.id) || [],
statuses: statusMap.get(p.id) || [],
taskCount: counts.total,
completedCount: counts.completed,
progress: counts.total > 0 ? Math.round((counts.completed / counts.total) * 100) : 0,
@@ -204,6 +233,11 @@ projectRoutes.post("/", async (c) => {
targetDate: data.targetDate ? new Date(data.targetDate) : null,
}).returning();
// Seed the default workflow statuses so a new project is usable immediately.
const seededStatuses = await db.insert(statusDefinitions).values(
DEFAULT_STATUSES.map((s) => ({ ...s, projectId: project.id }))
).returning();
if (data.tagIds && data.tagIds.length > 0) {
await db.insert(projectTags).values(
data.tagIds.map(tagId => ({ projectId: project.id, tagId }))
@@ -221,7 +255,7 @@ projectRoutes.post("/", async (c) => {
await enqueueWebhooks({ workspaceId: data.domain, event: "project.created", entityType: "project", entityId: project.id, data: { name: project.name } });
return c.json(project, 201);
return c.json({ ...project, statuses: seededStatuses }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
@@ -260,11 +294,22 @@ projectRoutes.get("/:id", async (c) => {
.where(eq(sections.projectId, id))
.orderBy(asc(sections.sortOrder));
// Fetch tasks
const projectTasks = await db.select()
// Fetch the project's status workflow
const projectStatuses = await db.select()
.from(statusDefinitions)
.where(eq(statusDefinitions.projectId, id))
.orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt));
const statusById = new Map(projectStatuses.map((s) => [s.id, s]));
// Fetch tasks (with their status definition embedded)
const taskColumns = db.select()
.from(tasks)
.where(and(eq(tasks.projectId, id), isNull(tasks.deletedAt)))
.orderBy(asc(tasks.order));
const projectTasks = (await taskColumns).map((t) => ({
...t,
status: t.statusId ? statusById.get(t.statusId) ?? null : null,
}));
// Fetch tags
const tagRows = await db.select({
@@ -277,13 +322,14 @@ 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.status?.category === "done").length;
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
return c.json({
...project,
sections: projectSections,
tasks: projectTasks,
statuses: projectStatuses,
tags: tagRows,
taskCount: totalTasks,
completedCount: completedTasks,
+389
View File
@@ -0,0 +1,389 @@
import { Hono } from "hono";
import { db, statusDefinitions, tasks, projects } from "@project-e/db";
import { and, asc, eq, isNull, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
import { z } from "zod";
export const statusRoutes = new Hono();
const statusCategoryEnum = z.enum(["todo", "in_progress", "done", "cancelled"]);
const MAX_STATUSES_PER_PROJECT = 15;
const createStatusSchema = z.object({
key: z
.string()
.regex(/^[a-z0-9_]+$/, "Key must be lowercase letters, numbers and underscores")
.optional(),
label: z.string().min(1, "Label is required").max(60),
category: statusCategoryEnum.optional().default("todo"),
color: z.string().optional().nullable(),
sortOrder: z.number().int().optional(),
isDefault: z.boolean().optional(),
});
const updateStatusSchema = z.object({
key: z
.string()
.regex(/^[a-z0-9_]+$/, "Key must be lowercase letters, numbers and underscores")
.optional(),
label: z.string().min(1).max(60).optional(),
category: statusCategoryEnum.optional(),
color: z.string().optional().nullable(),
sortOrder: z.number().int().optional(),
isDefault: z.boolean().optional(),
});
const reorderStatusSchema = z.object({
orderedIds: z.array(z.string().uuid("Invalid status id")).min(1, "orderedIds is required"),
});
function slugifyKey(label: string): string {
const key = label
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
return key || "status";
}
async function getProject(c: any, projectId: string) {
const [project] = await db
.select({ id: projects.id, name: projects.name, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
throw new AuthError("Project not found", 404, "NOT_FOUND");
}
await requireWorkspaceAccess(c, project.domainId);
return project;
}
// GET /api/projects/:projectId/statuses — List statuses for a project
statusRoutes.get("/:projectId/statuses", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
if (!isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const project = await getProject(c, projectId);
const items = await db.select()
.from(statusDefinitions)
.where(eq(statusDefinitions.projectId, projectId))
.orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt));
return c.json({ items, totalItems: items.length });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[statuses] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list statuses" } }, 500);
}
});
// POST /api/projects/:projectId/statuses — Create a status
statusRoutes.post("/:projectId/statuses", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
if (!isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = createStatusSchema.parse(body);
const project = await getProject(c, projectId);
// Cap the number of statuses per project
const [countResult] = await db.select({ count: sql<number>`count(*)` })
.from(statusDefinitions)
.where(eq(statusDefinitions.projectId, projectId));
if (Number(countResult?.count || 0) >= MAX_STATUSES_PER_PROJECT) {
return c.json({
error: { code: "VALIDATION_ERROR", message: `Maximum of ${MAX_STATUSES_PER_PROJECT} statuses per project`, details: { limit: MAX_STATUSES_PER_PROJECT } },
}, 400);
}
// Uniqueness: the DB has a unique (project_id, key) index; reject up front
// with a friendly error instead of surfacing a constraint violation.
const key = data.key ?? slugifyKey(data.label);
const [existing] = await db.select({ id: statusDefinitions.id })
.from(statusDefinitions)
.where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.key, key)))
.limit(1);
if (existing) {
return c.json({ error: { code: "CONFLICT", message: `A status with key "${key}" already exists` } }, 409);
}
let sortOrder = data.sortOrder;
if (sortOrder === undefined) {
const [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` })
.from(statusDefinitions)
.where(eq(statusDefinitions.projectId, projectId));
sortOrder = Number(maxOrder?.max ?? -1) + 1;
}
const [status] = await db.insert(statusDefinitions).values({
projectId,
key,
label: data.label,
category: data.category,
color: data.color ?? null,
sortOrder,
isDefault: data.isDefault ?? false,
}).returning();
if (status.isDefault) {
await db.update(statusDefinitions)
.set({ isDefault: false })
.where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.isDefault, true)));
await db.update(statusDefinitions)
.set({ isDefault: true })
.where(eq(statusDefinitions.id, status.id));
}
await recordActivity({
actor: user.name,
action: "created",
entityType: "status",
entityId: status.id,
changes: { key: status.key, label: status.label, category: status.category, projectId, projectName: project.name },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "status.created", entityType: "status", entityId: status.id, data: { key: status.key, label: status.label } });
return c.json(status, 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("[statuses] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create status" } }, 500);
}
});
// POST /api/projects/:projectId/statuses/reorder — Batch update sortOrder
statusRoutes.post("/:projectId/statuses/reorder", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
if (!isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { orderedIds } = reorderStatusSchema.parse(body);
const project = await getProject(c, projectId);
const existing = await db.select({ id: statusDefinitions.id })
.from(statusDefinitions)
.where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.isDefault, false)));
const validIds = new Set(existing.map((s) => s.id));
if (!orderedIds.every((id) => validIds.has(id))) {
return c.json({ error: { code: "NOT_FOUND", message: "One or more statuses not found in this project" } }, 404);
}
await db.transaction(async (tx) => {
for (let i = 0; i < orderedIds.length; i++) {
await tx.update(statusDefinitions)
.set({ sortOrder: i, updatedAt: new Date() })
.where(eq(statusDefinitions.id, orderedIds[i]));
}
});
await recordActivity({
actor: user.name,
action: "reordered",
entityType: "status",
entityId: orderedIds[0],
changes: { orderedIds, projectId },
workspaceId: project.domainId,
});
return c.json({ success: true, orderedIds });
} 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("[statuses] POST /reorder error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to reorder statuses" } }, 500);
}
});
// PATCH /api/projects/:projectId/statuses/:id — Update a status
statusRoutes.patch("/:projectId/statuses/:id", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
const id = c.req.param("id");
if (!isUuid(projectId) || !isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const data = updateStatusSchema.parse(body);
const project = await getProject(c, projectId);
const [existing] = await db.select()
.from(statusDefinitions)
.where(and(eq(statusDefinitions.id, id), eq(statusDefinitions.projectId, projectId)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
}
const updateValues: Record<string, unknown> = {};
if (data.key !== undefined) updateValues.key = data.key;
if (data.label !== undefined) updateValues.label = data.label;
if (data.category !== undefined) updateValues.category = data.category;
if (data.color !== undefined) updateValues.color = data.color;
if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder;
if (data.isDefault !== undefined) updateValues.isDefault = data.isDefault;
updateValues.updatedAt = new Date();
// Uniqueness check on the (project_id, key) pair
if (data.key !== undefined && data.key !== existing.key) {
const [conflict] = await db.select({ id: statusDefinitions.id })
.from(statusDefinitions)
.where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.key, data.key)))
.limit(1);
if (conflict && conflict.id !== id) {
return c.json({ error: { code: "CONFLICT", message: `A status with key "${data.key}" already exists` } }, 409);
}
}
if (data.isDefault === true) {
// Only one default per project
await db.update(statusDefinitions)
.set({ isDefault: false, updatedAt: new Date() })
.where(and(eq(statusDefinitions.projectId, projectId), eq(statusDefinitions.isDefault, true)));
}
const [updated] = await db.update(statusDefinitions)
.set(updateValues)
.where(eq(statusDefinitions.id, id))
.returning();
await recordActivity({
actor: user.name,
action: "updated",
entityType: "status",
entityId: id,
changes: { ...data, projectId, previousLabel: existing.label },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "status.updated", entityType: "status", entityId: id, data: { ...data, projectId } });
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("[statuses] PATCH error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update status" } }, 500);
}
});
// DELETE /api/projects/:projectId/statuses/:id — Delete a status
statusRoutes.delete("/:projectId/statuses/:id", async (c) => {
try {
const user = await requireAuth(c);
const projectId = c.req.param("projectId");
const id = c.req.param("id");
if (!isUuid(projectId) || !isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const project = await getProject(c, projectId);
const [existing] = await db.select()
.from(statusDefinitions)
.where(and(eq(statusDefinitions.id, id), eq(statusDefinitions.projectId, projectId)))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
}
// Never allow removing the last status — a project needs at least one.
const [countResult] = await db.select({ count: sql<number>`count(*)` })
.from(statusDefinitions)
.where(eq(statusDefinitions.projectId, projectId));
if (Number(countResult?.count || 0) <= 1) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "A project must have at least one status" } }, 400);
}
// Reassign tasks using this status to the project's default before deleting.
const [defaultStatus] = await db.select()
.from(statusDefinitions)
.where(and(
eq(statusDefinitions.projectId, projectId),
eq(statusDefinitions.isDefault, true),
))
.limit(1);
const fallbackId = defaultStatus?.id ?? null;
const tasksUsingStatus = await db.select({ id: tasks.id })
.from(tasks)
.where(and(eq(tasks.statusId, id), isNull(tasks.deletedAt)))
.limit(1);
if (tasksUsingStatus.length > 0) {
if (!fallbackId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Cannot delete this status: no default status exists to reassign its tasks" } }, 400);
}
await db.update(tasks)
.set({ statusId: fallbackId, updatedAt: new Date() })
.where(eq(tasks.statusId, id));
}
// If the default is being deleted, promote the first remaining status.
if (existing.isDefault) {
const [nextDefault] = await db.select({ id: statusDefinitions.id })
.from(statusDefinitions)
.where(and(
eq(statusDefinitions.projectId, projectId),
eq(statusDefinitions.isDefault, false),
))
.orderBy(asc(statusDefinitions.sortOrder))
.limit(1);
if (nextDefault) {
await db.update(statusDefinitions)
.set({ isDefault: true, updatedAt: new Date() })
.where(eq(statusDefinitions.id, nextDefault.id));
}
}
await db.delete(statusDefinitions).where(eq(statusDefinitions.id, id));
await recordActivity({
actor: user.name,
action: "deleted",
entityType: "status",
entityId: id,
changes: { key: existing.key, label: existing.label, projectId, reassignedToDefault: tasksUsingStatus.length > 0 },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "status.deleted", entityType: "status", entityId: id, data: { key: existing.key } });
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("[statuses] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete status" } }, 500);
}
});
+227 -28
View File
@@ -1,21 +1,60 @@
import { Hono } from "hono";
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections, statusDefinitions } from "@project-e/db";
import { and, asc, desc, eq, exists, getTableColumns, ilike, inArray, isNull, or, 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";
import { notifyWorkspaceOwner } from "../lib/notify";
import { evaluateAutomations } from "../lib/automation-engine";
import { z } from "zod";
import { RRule } from "rrule";
export const taskRoutes = new Hono();
const taskStatusEnum = z.enum(["todo", "in_progress", "done", "cancelled"]);
const STATUS_CATEGORIES = ["todo", "in_progress", "done", "cancelled"] as const;
const taskPriorityEnum = z.enum(["low", "medium", "high", "urgent"]);
// Columns of the status_definitions table flattened onto a task row as `status`
// (null when the task has no status or its status was deleted).
const statusColumns = {
id: statusDefinitions.id,
projectId: statusDefinitions.projectId,
key: statusDefinitions.key,
label: statusDefinitions.label,
category: statusDefinitions.category,
color: statusDefinitions.color,
sortOrder: statusDefinitions.sortOrder,
isDefault: statusDefinitions.isDefault,
};
type StatusRow = typeof statusDefinitions.$inferSelect;
async function getProjectStatuses(projectId: string | null | undefined): Promise<StatusRow[]> {
if (!projectId) return [];
return db.select().from(statusDefinitions)
.where(eq(statusDefinitions.projectId, projectId))
.orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt));
}
async function getDefaultStatusId(projectId: string | null | undefined): Promise<string | null> {
if (!projectId) return null;
const statuses = await getProjectStatuses(projectId);
return statuses.find((s) => s.isDefault)?.id ?? statuses[0]?.id ?? null;
}
async function getStatusCategory(statusId: string | null | undefined): Promise<string | null> {
if (!statusId) return null;
const [status] = await db.select({ category: statusDefinitions.category })
.from(statusDefinitions)
.where(eq(statusDefinitions.id, statusId))
.limit(1);
return status?.category ?? null;
}
const createTaskSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional().nullable(),
status: taskStatusEnum.optional().default("todo"),
statusId: z.string().uuid().optional().nullable(),
priority: taskPriorityEnum.optional().default("medium"),
domain: z.string().min(1, "Domain is required"),
projectId: z.string().uuid().optional().nullable(),
@@ -32,7 +71,7 @@ const createTaskSchema = z.object({
const updateTaskSchema = z.object({
title: z.string().min(1).optional(),
description: z.string().optional().nullable(),
status: taskStatusEnum.optional(),
statusId: z.string().uuid().optional().nullable(),
priority: taskPriorityEnum.optional(),
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
@@ -111,8 +150,16 @@ taskRoutes.get("/", async (c) => {
];
if (status) {
const statuses = status.split(",");
conditions.push(inArray(tasks.status, statuses as any));
// Filter by category name (todo/in_progress/...) or by status definition
// id (uuid). Category filters match every project's statuses in that
// category; id filters target one specific project status.
const values = status.split(",").map((v) => v.trim()).filter(Boolean);
const categoryValues = values.filter((v) => (STATUS_CATEGORIES as readonly string[]).includes(v));
const idValues = values.filter((v) => isUuid(v));
const statusConds: any[] = [];
if (categoryValues.length > 0) statusConds.push(inArray(statusDefinitions.category, categoryValues as any));
if (idValues.length > 0) statusConds.push(inArray(statusDefinitions.id, idValues));
if (statusConds.length > 0) conditions.push(or(...statusConds));
}
if (priority) {
const priorities = priority.split(",");
@@ -164,7 +211,7 @@ taskRoutes.get("/", async (c) => {
created: tasks.createdAt,
updated: tasks.updatedAt,
title: tasks.title,
status: tasks.status,
status: statusDefinitions.sortOrder,
priority: tasks.priority,
order: tasks.order,
due_date: tasks.dueDate,
@@ -175,15 +222,18 @@ taskRoutes.get("/", async (c) => {
? asc(sortColumns[sortField] || tasks.createdAt)
: desc(sortColumns[sortField] || tasks.createdAt);
const taskColumns = getTableColumns(tasks);
const [items, countResult] = await Promise.all([
db.select()
db.select({ ...taskColumns, status: statusColumns })
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(...conditions))
.orderBy(orderColumn)
.limit(limit || perPage)
.offset(offset || (page - 1) * perPage),
db.select({ count: sql<number>`count(*)` })
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(...conditions)),
]);
@@ -293,16 +343,37 @@ taskRoutes.post("/", async (c) => {
}
}
// Resolve the task's status definition. A status is per-project, so it may
// only be set when the task belongs to a project, and the status must belong
// to that project. When omitted, the project's default status is used.
let statusId = data.statusId ?? null;
if (data.statusId) {
const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId })
.from(statusDefinitions)
.where(eq(statusDefinitions.id, data.statusId))
.limit(1);
if (!status) {
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
}
if (!data.projectId || status.projectId !== data.projectId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Status does not belong to the selected project" } }, 400);
}
} else if (data.projectId) {
statusId = await getDefaultStatusId(data.projectId);
}
const statusCategory = await getStatusCategory(statusId);
const [task] = await db.insert(tasks).values({
title: data.title,
description: data.description ?? null,
status: data.status,
statusId,
priority: data.priority,
domainId: data.domain,
projectId: data.projectId ?? null,
sectionId: data.sectionId ?? null,
parentId: data.parentId ?? null,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
completedAt: statusCategory === "done" ? new Date() : null,
estimatedMinutes: data.estimatedMinutes ?? null,
order: data.order ?? 0,
customFields: data.customFields ?? {},
@@ -320,16 +391,40 @@ 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, statusId: task.statusId, priority: task.priority },
workspaceId: data.domain,
});
await enqueueWebhooks({ workspaceId: data.domain, event: "task.created", entityType: "task", entityId: task.id, data: { title: task.title } });
// MVP single-user: the workspace owner is the only assignee, so a new task
// is treated as an assignment to them.
try {
await notifyWorkspaceOwner({
workspaceId: data.domain,
type: "assignment",
title: "New task assigned to you",
body: `"${task.title}" was created${user.name ? ` by ${user.name}` : ""}`,
entityType: "task",
entityId: task.id,
});
} catch (notifyError) {
console.error(`[tasks] Failed to create assignment notification for task ${task.id}:`, notifyError);
}
if (data.recurrenceRule) {
await syncScheduledJob(task.id, data.recurrenceRule);
}
// Fire "task created" automation rules (e.g. set a default priority/label).
await evaluateAutomations({
projectId: task.projectId,
triggerType: "task_created",
entity: task,
changes: {},
actor: user.name,
});
return c.json(task, 201);
} catch (error) {
if (error instanceof AuthError) {
@@ -417,8 +512,10 @@ taskRoutes.get("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [task] = await db.select()
const taskColumns = getTableColumns(tasks);
const [task] = await db.select({ ...taskColumns, status: statusColumns })
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
@@ -429,8 +526,9 @@ taskRoutes.get("/:id", async (c) => {
await requireWorkspaceAccess(c, task.domainId);
// Fetch subtasks
const subtasks = await db.select()
const subtasks = await db.select({ ...taskColumns, status: statusColumns })
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(eq(tasks.parentId, id), isNull(tasks.deletedAt)))
.orderBy(asc(tasks.order));
@@ -448,20 +546,24 @@ taskRoutes.get("/:id", async (c) => {
const depRows = await db.select({
id: tasks.id,
title: tasks.title,
status: tasks.status,
statusId: tasks.statusId,
status: statusColumns,
})
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id))
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.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,
statusId: tasks.statusId,
status: statusColumns,
})
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.taskId, tasks.id))
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt)));
return c.json({
@@ -525,7 +627,6 @@ 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;
@@ -535,6 +636,31 @@ taskRoutes.patch("/:id", async (c) => {
if (data.order !== undefined) updateValues.order = data.order;
if (data.customFields !== undefined) updateValues.customFields = data.customFields;
if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule;
// Status change: validate the status belongs to the task's project and keep
// completedAt in sync with the "done" category.
let newStatusLabel: string | null = null;
if (data.statusId !== undefined) {
const statusId = data.statusId ?? null;
if (statusId) {
const finalProjectId = (data.projectId !== undefined ? data.projectId : existing.projectId) ?? null;
const [status] = await db.select({ id: statusDefinitions.id, projectId: statusDefinitions.projectId, category: statusDefinitions.category, label: statusDefinitions.label })
.from(statusDefinitions)
.where(eq(statusDefinitions.id, statusId))
.limit(1);
if (!status) {
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
}
if (!finalProjectId || status.projectId !== finalProjectId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Status does not belong to the task's project" } }, 400);
}
updateValues.completedAt = status.category === "done" ? new Date() : null;
newStatusLabel = status.label;
} else {
updateValues.completedAt = null;
}
updateValues.statusId = statusId;
}
updateValues.updatedAt = new Date();
const [updated] = await db.update(tasks)
@@ -547,16 +673,44 @@ taskRoutes.patch("/:id", async (c) => {
action: "updated",
entityType: "task",
entityId: id,
changes: { ...data, previousStatus: existing.status },
changes: { ...data, previousStatusId: existing.statusId },
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, previousStatusId: existing.statusId } });
// Notify on actual status changes only (not unrelated field updates).
if (data.statusId !== undefined && data.statusId !== existing.statusId) {
try {
await notifyWorkspaceOwner({
workspaceId: existing.domainId,
type: "status_change",
title: `Task moved to ${newStatusLabel ?? "a new status"}`,
body: `"${updated.title}" ${newStatusLabel ? `is now ${newStatusLabel}` : "changed status"}`,
entityType: "task",
entityId: id,
});
} catch (notifyError) {
console.error(`[tasks] Failed to create status-change notification for task ${id}:`, notifyError);
}
}
if (data.recurrenceRule !== undefined) {
await syncScheduledJob(id, data.recurrenceRule);
}
// Fire "status changed" automation rules when the status actually moved
// (e.g. when a status changes to Done, add a "shipped" label).
if (data.statusId !== undefined && data.statusId !== existing.statusId) {
await evaluateAutomations({
projectId: updated.projectId,
triggerType: "task_status_changed",
entity: updated,
changes: { previousStatusId: existing.statusId },
actor: user.name,
});
}
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) {
@@ -838,7 +992,7 @@ taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
}
});
// POST /api/tasks/:id/status — Change task status (Kanban drag)
// POST /api/tasks/:id/status — Change task status (Kanban drag / completion)
taskRoutes.post("/:id/status", async (c) => {
try {
const user = await requireAuth(c);
@@ -847,8 +1001,8 @@ taskRoutes.post("/:id/status", async (c) => {
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,
const { statusId } = z.object({
statusId: z.string().uuid("Invalid status id"),
}).parse(body);
const [existing] = await db.select()
@@ -862,13 +1016,33 @@ taskRoutes.post("/:id/status", async (c) => {
await requireWorkspaceAccess(c, existing.domainId);
if (!existing.projectId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Tasks without a project cannot have a status" } }, 400);
}
// The status must belong to the task's project.
const [status] = await db.select({
id: statusDefinitions.id,
projectId: statusDefinitions.projectId,
category: statusDefinitions.category,
label: statusDefinitions.label,
})
.from(statusDefinitions)
.where(eq(statusDefinitions.id, statusId))
.limit(1);
if (!status) {
return c.json({ error: { code: "NOT_FOUND", message: "Status not found" } }, 404);
}
if (status.projectId !== existing.projectId) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Status does not belong to the task's project" } }, 400);
}
const isDone = status.category === "done";
const updateValues: Record<string, unknown> = {
status: newStatus,
statusId,
completedAt: isDone ? new Date() : null,
updatedAt: new Date(),
};
if (newStatus === "done") {
updateValues.completedAt = new Date();
}
const [updated] = await db.update(tasks)
.set(updateValues)
@@ -877,14 +1051,39 @@ taskRoutes.post("/:id/status", async (c) => {
await recordActivity({
actor: user.name,
action: newStatus === "done" ? "completed" : "updated",
action: isDone ? "completed" : "updated",
entityType: "task",
entityId: id,
changes: { previousStatus: existing.status, newStatus },
changes: { previousStatusId: existing.statusId, newStatusId: statusId, newStatusLabel: status.label },
workspaceId: existing.domainId,
});
return c.json(updated);
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { previousStatusId: existing.statusId, newStatusId: statusId } });
// Fire "status changed" automation rules (Kanban drags and done checkboxes).
await evaluateAutomations({
projectId: updated.projectId,
triggerType: "task_status_changed",
entity: updated,
changes: { previousStatusId: existing.statusId },
actor: user.name,
});
// Notify the workspace owner (MVP single-user) of the status change.
try {
await notifyWorkspaceOwner({
workspaceId: existing.domainId,
type: "status_change",
title: `Task moved to ${status.label}`,
body: `"${existing.title}" is now ${status.label}`,
entityType: "task",
entityId: id,
});
} catch (notifyError) {
console.error(`[tasks] Failed to create status-change notification for task ${id}:`, notifyError);
}
return c.json({ ...updated, status });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
+118
View File
@@ -0,0 +1,118 @@
import { Hono } from "hono";
import { db, projects, sections, statusDefinitions, taskDependencies, tasks } from "@project-e/db";
import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
export const timelineRoutes = new Hono();
// Columns of the status_definitions table flattened onto a task row as `status`
// (null when the task has no status or its status was deleted).
const statusColumns = {
id: statusDefinitions.id,
projectId: statusDefinitions.projectId,
key: statusDefinitions.key,
label: statusDefinitions.label,
category: statusDefinitions.category,
color: statusDefinitions.color,
sortOrder: statusDefinitions.sortOrder,
isDefault: statusDefinitions.isDefault,
};
// GET /api/domains/:domainId/projects/:projectId/timeline — Gantt data for a project:
// task bars (with status + dependencies) and milestone sections. Tasks without a
// startDate field use createdAt as the bar start.
timelineRoutes.get("/domains/:domainId/projects/:projectId/timeline", async (c) => {
try {
const user = await requireAuth(c);
void user;
const domainId = c.req.param("domainId");
const projectId = c.req.param("projectId");
if (!isUuid(domainId) || !isUuid(projectId)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
await requireWorkspaceAccess(c, domainId);
const [project] = await db.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
const [taskRows, milestoneRows] = await Promise.all([
db.select({
id: tasks.id,
title: tasks.title,
statusId: tasks.statusId,
sectionId: tasks.sectionId,
dueDate: tasks.dueDate,
createdAt: tasks.createdAt,
status: statusColumns,
})
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt)))
.orderBy(asc(tasks.createdAt)),
db.select({
id: sections.id,
name: sections.name,
targetDate: sections.targetDate,
sortOrder: sections.sortOrder,
})
.from(sections)
.where(and(
eq(sections.projectId, projectId),
eq(sections.kind, "milestone"),
isNotNull(sections.targetDate),
))
.orderBy(asc(sections.targetDate), asc(sections.sortOrder)),
]);
// Dependency map: taskId → ids of tasks it depends on. Only edges between
// tasks in this project are kept so arrows never point outside the chart.
const depsByTask = new Map<string, string[]>();
if (taskRows.length > 0) {
const taskIds = taskRows.map((t) => t.id);
const depRows = await db.select({
taskId: taskDependencies.taskId,
dependsOnTaskId: taskDependencies.dependsOnTaskId,
})
.from(taskDependencies)
.where(and(
inArray(taskDependencies.taskId, taskIds),
inArray(taskDependencies.dependsOnTaskId, taskIds),
));
for (const dep of depRows) {
const list = depsByTask.get(dep.taskId) ?? [];
list.push(dep.dependsOnTaskId);
depsByTask.set(dep.taskId, list);
}
}
return c.json({
tasks: taskRows.map((t) => ({
id: t.id,
title: t.title,
startDate: t.createdAt,
dueDate: t.dueDate,
statusId: t.statusId,
status: t.status,
sectionId: t.sectionId,
dependencies: depsByTask.get(t.id) ?? [],
})),
milestones: milestoneRows.map((m) => ({
id: m.id,
name: m.name,
targetDate: m.targetDate,
})),
});
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[timeline] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to load timeline" } }, 500);
}
});