refactor: remove canvas, automations, and custom statuses; simplify notification and status model

This commit is contained in:
2026-08-22 18:00:41 +00:00
parent 7b2cdc3bae
commit ffc50091b1
69 changed files with 834 additions and 6438 deletions
-8
View File
@@ -10,8 +10,6 @@ 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";
@@ -19,7 +17,6 @@ import { graphRoutes } from "./routes/graph";
import { dashboardRoutes } from "./routes/dashboard";
import { agentRoutes } from "./routes/agents";
import { webhookRoutes } from "./routes/webhooks";
import { canvasRoutes } from "./routes/canvas";
import { commentRoutes } from "./routes/comments";
import { dailyNoteRoutes } from "./routes/daily-notes";
import { tagRoutes } from "./routes/tags";
@@ -29,7 +26,6 @@ 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();
@@ -51,8 +47,6 @@ 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);
@@ -60,7 +54,6 @@ app.route("/api/graph", graphRoutes);
app.route("/api/dashboard", dashboardRoutes);
app.route("/api/agents", agentRoutes);
app.route("/api/webhooks", webhookRoutes);
app.route("/api/canvas", canvasRoutes);
app.route("/api/comments", commentRoutes);
app.route("/api/daily-notes", dailyNoteRoutes);
app.route("/api/tags", tagRoutes);
@@ -71,7 +64,6 @@ 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
@@ -1,291 +0,0 @@
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
@@ -1,65 +0,0 @@
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 });
}
-2
View File
@@ -6,7 +6,6 @@ import {
habits,
projects,
notes,
canvases,
dailyNotes,
calendarEvents,
webhooks,
@@ -69,7 +68,6 @@ const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
webhook: { table: webhooks, idColumn: webhooks.id, workspaceColumn: webhooks.workspaceId },
+1 -5
View File
@@ -6,7 +6,6 @@ import {
projects,
habits,
notes,
canvases,
dailyNotes,
calendarEvents,
} from "@project-e/db";
@@ -20,7 +19,7 @@ export const activityRoutes = new Hono();
// Entity types that participate in the generic activity feed / comments API.
// The comments table stores the same entityType strings, so keep this enum and
// the entityWorkspaceLookups map below in sync.
const entityTypeEnum = z.enum(["task", "project", "habit", "note", "canvas", "daily_note", "calendar_event"]);
const entityTypeEnum = z.enum(["task", "project", "habit", "note", "daily_note", "calendar_event"]);
interface EntityWorkspaceLookup {
table: AnyPgTable;
@@ -28,14 +27,11 @@ interface EntityWorkspaceLookup {
workspaceColumn: AnyPgColumn;
}
// Maps an entityType (as recorded in activity_feed) to the table + column that
// holds its owning workspace/domain. All of these tables use domain_id.
const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId },
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
};
+32
View File
@@ -329,6 +329,38 @@ agentRoutes.get("/:id/permissions", async (c) => {
}
});
// POST /api/agents/dispatch — Stub for @mention dispatch (enqueues worker job)
agentRoutes.post("/dispatch", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const domainId = body.workspaceId || body.domain || (await resolveActiveDomain(user)).id;
await requireWorkspaceAccess(c, domainId);
const agentsList = await db.select().from(agents).where(and(eq(agents.domainId, domainId), eq(agents.status, "active"))).limit(1);
if (agentsList.length === 0) return c.json({ success: false, message: "No active agents" }, 200);
const agent = agentsList[0];
await db.insert(agentTasks).values({
agentId: agent.id,
taskType: "mention",
input: { commentId: body.commentId, entityType: body.entityType, entityId: body.entityId, author: user.name },
status: "pending",
});
await db.insert(agentActivity).values({
agentId: agent.id,
action: "mentioned",
entityType: body.entityType || "comment",
entityId: body.entityId,
details: { commentId: body.commentId },
success: true,
});
return c.json({ success: true, agentId: agent.id });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[agents] POST /dispatch error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Dispatch failed" } }, 500);
}
});
// GET /api/agents/:id/activity — Agent activity log (or all if id=_all)
agentRoutes.get("/:id/activity", async (c) => {
try {
+115 -18
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { db, tasks, habits, habitCompletions, projects, statusDefinitions } from "@project-e/db";
import { and, eq, gte, getTableColumns, inArray, isNull, or } from "drizzle-orm";
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
import { and, eq, gte, inArray, isNull, or } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
export const analyticsRoutes = new Hono();
@@ -22,18 +22,15 @@ analyticsRoutes.get("/productivity", async (c) => {
const startDate = new Date();
startDate.setDate(startDate.getDate() - range);
const taskColumns = getTableColumns(tasks);
const allTasks = await db.select({ ...taskColumns, statusCategory: statusDefinitions.category })
const allTasks = await db.select()
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(
eq(tasks.domainId, domainId),
gte(tasks.createdAt, startDate),
isNull(tasks.deletedAt),
));
// "Done" is a status category; a status marked category='done' completes a task.
const completedTasks = allTasks.filter(t => t.statusCategory === "done");
const completedTasks = allTasks.filter(t => t.status === "done");
const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0;
return c.json({
@@ -51,7 +48,7 @@ analyticsRoutes.get("/productivity", async (c) => {
}
});
// GET /api/analytics/habits?range=... — Habit completion rate
// GET /api/analytics/habits?range=... — Habit completion rate (fixed per-habit expected)
analyticsRoutes.get("/habits", async (c) => {
try {
const user = await requireAuth(c);
@@ -74,7 +71,6 @@ analyticsRoutes.get("/habits", async (c) => {
const habitIds = allHabits.map((h) => h.id);
// Only count completions belonging to habits in this domain (not all completions globally)
const allLogs = habitIds.length > 0
? await db.select()
.from(habitCompletions)
@@ -84,8 +80,45 @@ analyticsRoutes.get("/habits", async (c) => {
))
: [];
const habitConsistency = allHabits.length > 0
? Math.round((allLogs.length / (allHabits.length * range)) * 100)
const logsByHabit = new Map<string, number>();
for (const lg of allLogs) logsByHabit.set(lg.habitId, (logsByHabit.get(lg.habitId) || 0) + 1);
const expectedForHabit = (h: typeof allHabits[number]) => {
if (!h.active) return 0;
const skipSet = new Set(h.skipDays || []);
if (h.frequency === "daily") {
let expected = 0;
const cursor = new Date(startDate);
for (let i = 0; i < range; i++) {
if (!skipSet.has(cursor.getUTCDay())) expected += h.goalPerPeriod || 1;
cursor.setDate(cursor.getDate() + 1);
}
return expected;
}
if (h.frequency === "weekly") {
const weeks = Math.ceil(range / 7);
return weeks * (h.goalPerPeriod || 1);
}
return range * (h.goalPerPeriod || 1);
};
let totalExpected = 0;
const perHabit: Array<{ id: string; name: string; completed: number; expected: number; consistency: number }> = [];
for (const h of allHabits) {
const expected = expectedForHabit(h);
const completed = logsByHabit.get(h.id) || 0;
totalExpected += expected;
perHabit.push({
id: h.id,
name: h.name,
completed,
expected,
consistency: expected > 0 ? Math.min(100, Math.round((completed / expected) * 100)) : 0,
});
}
const habitConsistency = totalExpected > 0
? Math.min(100, Math.round((allLogs.length / totalExpected) * 100))
: 0;
const activeStreaks = allHabits.filter(h => (h.streakCount || 0) > 0);
@@ -95,6 +128,8 @@ analyticsRoutes.get("/habits", async (c) => {
habitConsistency,
totalHabits: allHabits.length,
totalLogs: allLogs.length,
totalExpected,
perHabit,
activeStreaks: activeStreaks.length,
bestStreak,
period: range,
@@ -130,9 +165,8 @@ 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, statusCategory: statusDefinitions.category })
? await db.select({ projectId: tasks.projectId, status: tasks.status })
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(
isNull(tasks.deletedAt),
inArray(tasks.projectId, projectIds),
@@ -144,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.statusCategory === "done") entry.completedTasks += 1;
if (t.status === "done") entry.completedTasks += 1;
counts.set(t.projectId, entry);
}
@@ -176,6 +210,71 @@ analyticsRoutes.get("/projects", async (c) => {
}
});
// GET /api/analytics/velocity?range=... — Tasks completed per day (used by new dashboard)
analyticsRoutes.get("/velocity", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const range = parseInt(url.searchParams.get("range") || "30");
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const firstDay = new Date();
firstDay.setDate(firstDay.getDate() - (range - 1));
firstDay.setHours(0, 0, 0, 0);
const domainTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.completedAt, firstDay)));
const localDateKey = (d: Date) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
const byDay = new Map<string, number>();
for (const t of domainTasks) if (t.completedAt) {
const k = localDateKey(t.completedAt);
byDay.set(k, (byDay.get(k)||0)+1);
}
const items: Array<{ date: string; completed: number }> = [];
const cursor = new Date(firstDay);
for (let i=0;i<range;i++) {
const k = localDateKey(cursor);
items.push({ date: k, completed: byDay.get(k)||0 });
cursor.setDate(cursor.getDate()+1);
}
const avg = items.reduce((s,i)=>s+i.completed,0)/range;
return c.json({ items, avg: Math.round(avg*10)/10, period: range }, { headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" } });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[analytics] GET /velocity error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get velocity" } }, 500);
}
});
analyticsRoutes.get("/cycle", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const range = parseInt(url.searchParams.get("range") || "30");
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
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 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);
const median = durations.length ? durations[Math.floor(durations.length/2)] : 0;
const avg = durations.length ? durations.reduce((s,v)=>s+v,0)/durations.length : 0;
return c.json({ median: Math.round(median*10)/10, avg: Math.round(avg*10)/10, count: durations.length, period: range }, { headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" } });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[analytics] GET /cycle error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get cycle" } }, 500);
}
});
// GET /api/analytics/daily?range=... — Daily task creation & completion time series
analyticsRoutes.get("/daily", async (c) => {
try {
@@ -195,10 +294,8 @@ analyticsRoutes.get("/daily", async (c) => {
firstDay.setDate(firstDay.getDate() - (range - 1));
firstDay.setHours(0, 0, 0, 0);
const dailyTaskColumns = getTableColumns(tasks);
const domainTasks = await db.select({ ...dailyTaskColumns, statusCategory: statusDefinitions.category })
const domainTasks = await db.select()
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(
eq(tasks.domainId, domainId),
isNull(tasks.deletedAt),
@@ -222,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.statusCategory === "done" && t.completedAt) {
if (t.status === "done" && t.completedAt) {
const completedKey = localDateKey(t.completedAt);
completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1);
}
-309
View File
@@ -1,309 +0,0 @@
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);
}
});
+52 -1
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { db, calendarEvents } from "@project-e/db";
import { db, calendarEvents, tasks, habits } from "@project-e/db";
import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm";
import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
@@ -222,6 +222,57 @@ calendarRoutes.delete("/events/:id", async (c) => {
}
});
// GET /api/calendar/unified?from=&to=&domain — Merged timeline: calendar_events + tasks due + habit reminders
calendarRoutes.get("/unified", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const fromStr = url.searchParams.get("from");
const toStr = url.searchParams.get("to");
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const from = fromStr ? new Date(fromStr) : new Date(new Date().setDate(new Date().getDate() - 7));
const to = toStr ? new Date(toStr) : new Date(new Date().setDate(new Date().getDate() + 30));
const [events, domainTasks, domainHabits] = await Promise.all([
db.select().from(calendarEvents).where(and(eq(calendarEvents.domainId, domainId), gte(calendarEvents.startTime, from), lte(calendarEvents.startTime, to))).orderBy(asc(calendarEvents.startTime)),
db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), gte(tasks.dueDate, from), lte(tasks.dueDate, to))).orderBy(asc(tasks.dueDate)),
db.select().from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))),
]);
const taskEvents = domainTasks.filter(t => t.dueDate).map(t => ({
id: `task-${t.id}`, title: t.title, description: null, startTime: t.dueDate, endTime: null, allDay: true, color: "#3b82f6", domainId, entityType: "task", entityId: t.id, recurrenceRule: null, createdAt: t.createdAt, updatedAt: t.updatedAt,
}));
const habitEvents: typeof events = [];
for (const h of domainHabits) {
if (!h.reminderTime || !h.active) continue;
const [hh, mm] = h.reminderTime.split(":").map(Number);
const cursor = new Date(from);
cursor.setHours(0,0,0,0);
while (cursor <= to) {
const skip = (h.skipDays || []).includes(cursor.getDay());
if (!skip) {
const dt = new Date(cursor);
dt.setHours(hh||9, mm||0, 0, 0);
habitEvents.push({
id: `habit-${h.id}-${dt.toISOString().slice(0,10)}`, title: h.name, description: h.description, startTime: dt, endTime: null, allDay: false, color: "#f59e0b", domainId, entityType: "habit", entityId: h.id, recurrenceRule: null, createdAt: h.createdAt, updatedAt: h.updatedAt,
} as any);
}
cursor.setDate(cursor.getDate()+1);
if (h.frequency === "weekly" && habitEvents.filter(e=>e.entityId===h.id).length >= 2) break;
}
}
const items = [...events, ...taskEvents as any, ...habitEvents].sort((a,b)=> new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
return c.json({ items, totalItems: items.length, breakdown: { events: events.length, tasks: taskEvents.length, habits: habitEvents.length } });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[calendar] GET /unified error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get unified calendar" } }, 500);
}
});
// GET /api/calendar/upcoming?days=7 — Next N days
calendarRoutes.get("/upcoming", async (c) => {
try {
-423
View File
@@ -1,423 +0,0 @@
import { Hono } from "hono";
import { db, canvases, canvasCards, canvasConnections } from "@project-e/db";
import { and, asc, desc, eq, notInArray, or, sql } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { z } from "zod";
export const canvasRoutes = new Hono();
const createCanvasSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional().nullable(),
mode: z.enum(["freeform", "graph"]).optional().default("freeform"),
domain: z.string().min(1, "Domain is required"),
tags: z.array(z.string()).optional().default([]),
viewport: z.object({ x: z.number().default(0), y: z.number().default(0), zoom: z.number().positive().default(1) }).optional(),
background: z.string().optional().nullable(),
customFields: z.record(z.string(), z.unknown()).optional(),
});
const updateCanvasSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
mode: z.enum(["freeform", "graph"]).optional(),
tags: z.array(z.string()).optional(),
viewport: z.object({ x: z.number(), y: z.number(), zoom: z.number().positive() }).optional(),
background: z.string().optional().nullable(),
customFields: z.record(z.string(), z.unknown()).optional(),
});
const createCardSchema = z.object({
type: z.string().min(1).default("note"),
content: z.string().optional().nullable().default(""),
title: z.string().optional().nullable(),
x: z.number().int().optional(),
y: z.number().int().optional(),
width: z.number().int().optional(),
height: z.number().int().optional(),
rotation: z.number().int().optional(),
color: z.string().optional().nullable(),
zIndex: z.number().int().optional(),
});
const updateCardSchema = createCardSchema.partial();
const bulkSaveCardsSchema = z.object({
cards: z.array(
z.object({
id: z.string().uuid().optional(),
type: z.string().min(1).default("note"),
content: z.string().optional().nullable().default(""),
title: z.string().optional().nullable(),
x: z.number().int().optional(),
y: z.number().int().optional(),
width: z.number().int().optional(),
height: z.number().int().optional(),
rotation: z.number().int().optional(),
color: z.string().optional().nullable(),
zIndex: z.number().int().optional(),
})
).default([]),
});
// GET /api/canvas — List canvases
canvasRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
const url = new URL(c.req.url);
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"));
const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
const sort = url.searchParams.get("sort") || "-created";
let domainId = url.searchParams.get("domain") || undefined;
if (!domainId) {
const active = await resolveActiveDomain(user);
domainId = active.id;
}
await requireWorkspaceAccess(c, domainId);
const conditions: any[] = [eq(canvases.domainId, domainId)];
const sortField = sort.replace(/^-/, "");
const sortDir = sort.startsWith("-") ? "desc" : "asc";
const sortColumns: Record<string, any> = { created: canvases.createdAt, updated: canvases.updatedAt, name: canvases.name };
const orderColumn = sortDir === "asc" ? asc(sortColumns[sortField] || canvases.createdAt) : desc(sortColumns[sortField] || canvases.createdAt);
const [items, countResult] = await Promise.all([
db.select().from(canvases).where(and(...conditions)).orderBy(orderColumn).limit(perPage).offset((page - 1) * perPage),
db.select({ count: sql<number>`count(*)` }).from(canvases).where(and(...conditions)),
]);
return c.json({ items, totalItems: Number(countResult[0]?.count || 0), totalPages: Math.ceil(Number(countResult[0]?.count || 0) / perPage), page, perPage });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[canvas] GET error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list canvases" } }, 500);
}
});
// POST /api/canvas — Create
canvasRoutes.post("/", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = createCanvasSchema.parse({
...body,
domain: body.domain || (await resolveActiveDomain(user)).id,
});
await requireWorkspaceAccess(c, data.domain);
const [canvas] = await db.insert(canvases).values({
name: data.name,
description: data.description ?? null,
mode: data.mode,
domainId: data.domain,
tags: data.tags ?? [],
viewport: data.viewport ?? { x: 0, y: 0, zoom: 1 },
background: data.background ?? null,
customFields: data.customFields ?? {},
}).returning();
await recordActivity({
actor: user.name, action: "created", entityType: "canvas", entityId: canvas.id,
changes: { name: canvas.name }, workspaceId: data.domain,
});
return c.json(canvas, 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("[canvas] POST error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create canvas" } }, 500);
}
});
// GET /api/canvas/:id — Read one (full block tree)
canvasRoutes.get("/:id", async (c) => {
try {
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 [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, canvas.domainId);
const [cards, connections] = await Promise.all([
db.select().from(canvasCards).where(eq(canvasCards.canvasId, id)).orderBy(asc(canvasCards.zIndex)),
db.select().from(canvasConnections).where(eq(canvasConnections.canvasId, id)),
]);
return c.json({ ...canvas, cards, connections });
} catch (error) {
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
console.error("[canvas] GET /:id error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get canvas" } }, 500);
}
});
// PATCH /api/canvas/:id — Update blocks
canvasRoutes.patch("/:id", 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 data = updateCanvasSchema.parse(body);
const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
if (data.description !== undefined) updateValues.description = data.description;
if (data.mode !== undefined) updateValues.mode = data.mode;
if (data.tags !== undefined) updateValues.tags = data.tags;
if (data.viewport !== undefined) updateValues.viewport = data.viewport;
if (data.background !== undefined) updateValues.background = data.background;
if (data.customFields !== undefined) updateValues.customFields = data.customFields;
updateValues.updatedAt = new Date();
const [updated] = await db.update(canvases).set(updateValues).where(eq(canvases.id, id)).returning();
await recordActivity({
actor: user.name, action: "updated", entityType: "canvas", entityId: id,
changes: { name: updated.name }, 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("[canvas] PATCH error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update canvas" } }, 500);
}
});
// DELETE /api/canvas/:id — Delete
canvasRoutes.delete("/:id", 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 [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, existing.domainId);
await db.delete(canvases).where(eq(canvases.id, id));
await recordActivity({
actor: user.name, action: "deleted", entityType: "canvas", entityId: id,
changes: { name: existing.name }, workspaceId: existing.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("[canvas] DELETE error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas" } }, 500);
}
});
// POST /api/canvas/:id/cards — Create one card (new block)
canvasRoutes.post("/:id/cards", 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 data = createCardSchema.parse(body);
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, canvas.domainId);
// New cards append to the end of the vertical document unless an explicit zIndex is given
const [maxRow] = await db
.select({ max: sql<number>`max(${canvasCards.zIndex})` })
.from(canvasCards)
.where(eq(canvasCards.canvasId, id));
const zIndex = data.zIndex ?? Number(maxRow?.max ?? -1) + 1;
const [card] = await db.insert(canvasCards).values({
canvasId: id,
type: data.type,
content: data.content ?? "",
title: data.title ?? null,
x: data.x ?? 0,
y: data.y ?? 0,
width: data.width ?? 200,
height: data.height ?? 150,
rotation: data.rotation ?? 0,
color: data.color ?? null,
zIndex,
}).returning();
await recordActivity({
actor: user.name, action: "created", entityType: "canvas_card", entityId: card.id,
changes: { type: card.type, zIndex: card.zIndex }, workspaceId: canvas.domainId,
});
return c.json(card, 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("[canvas] POST /:id/cards error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create canvas card" } }, 500);
}
});
// PUT /api/canvas/:id/cards — Bulk replace all cards (primary save path)
canvasRoutes.put("/:id/cards", 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 data = bulkSaveCardsSchema.parse(body);
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1);
if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
await requireWorkspaceAccess(c, canvas.domainId);
const cards = await db.transaction(async (tx) => {
await tx.delete(canvasCards).where(eq(canvasCards.canvasId, id));
if (data.cards.length === 0) {
// No cards left — drop every connection on this canvas.
await tx.delete(canvasConnections).where(or(
eq(canvasConnections.sourceCardId, id),
eq(canvasConnections.targetCardId, id),
));
return [];
}
const inserted = await tx.insert(canvasCards).values(
data.cards.map((card, i) => ({
...(card.id ? { id: card.id } : {}),
canvasId: id,
type: card.type,
content: card.content ?? "",
title: card.title ?? null,
x: card.x ?? 0,
y: card.y ?? 0,
width: card.width ?? 200,
height: card.height ?? 150,
rotation: card.rotation ?? 0,
color: card.color ?? null,
zIndex: card.zIndex ?? i,
}))
).returning();
// Connections to cards that no longer exist must not linger. Cards that
// were re-inserted with their original id keep their connections; any
// connection whose endpoint is missing is dropped.
const keptIds = inserted.map((c) => c.id);
await tx.delete(canvasConnections).where(or(
notInArray(canvasConnections.sourceCardId, keptIds),
notInArray(canvasConnections.targetCardId, keptIds),
));
return inserted;
});
await recordActivity({
actor: user.name, action: "updated", entityType: "canvas", entityId: id,
changes: { name: canvas.name, cardCount: cards.length }, workspaceId: canvas.domainId,
});
return c.json({ ...canvas, cards });
} 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("[canvas] PUT /:id/cards error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to save canvas cards" } }, 500);
}
});
// PATCH /api/canvas/cards/:cardId — Update one card
canvasRoutes.patch("/cards/:cardId", async (c) => {
try {
const user = await requireAuth(c);
const cardId = c.req.param("cardId");
const body = await c.req.json();
const data = updateCardSchema.parse(body);
const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1);
if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404);
// canvas_cards has no domain_id — resolve ownership through the parent canvas
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1);
if (canvas) {
await requireWorkspaceAccess(c, canvas.domainId);
}
const updateValues: Record<string, unknown> = {};
if (data.type !== undefined) updateValues.type = data.type;
if (data.content !== undefined) updateValues.content = data.content;
if (data.title !== undefined) updateValues.title = data.title;
if (data.x !== undefined) updateValues.x = data.x;
if (data.y !== undefined) updateValues.y = data.y;
if (data.width !== undefined) updateValues.width = data.width;
if (data.height !== undefined) updateValues.height = data.height;
if (data.rotation !== undefined) updateValues.rotation = data.rotation;
if (data.color !== undefined) updateValues.color = data.color;
if (data.zIndex !== undefined) updateValues.zIndex = data.zIndex;
updateValues.updatedAt = new Date();
const [updated] = await db.update(canvasCards).set(updateValues).where(eq(canvasCards.id, cardId)).returning();
await recordActivity({
actor: user.name, action: "updated", entityType: "canvas_card", entityId: cardId,
changes: { ...data }, workspaceId: canvas?.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("[canvas] PATCH /cards/:cardId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update canvas card" } }, 500);
}
});
// DELETE /api/canvas/cards/:cardId — Delete one card
canvasRoutes.delete("/cards/:cardId", async (c) => {
try {
const user = await requireAuth(c);
const cardId = c.req.param("cardId");
const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1);
if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404);
// canvas_cards has no domain_id — resolve ownership through the parent canvas
const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1);
if (canvas) {
await requireWorkspaceAccess(c, canvas.domainId);
}
// Hard delete — canvas_cards has no deleted_at column
await db.delete(canvasCards).where(eq(canvasCards.id, cardId));
await recordActivity({
actor: user.name, action: "deleted", entityType: "canvas_card", entityId: cardId,
changes: { type: card.type }, workspaceId: canvas?.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("[canvas] DELETE /cards/:cardId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas card" } }, 500);
}
});
+1 -21
View File
@@ -6,7 +6,6 @@ import {
projects,
habits,
notes,
canvases,
dailyNotes,
calendarEvents,
} from "@project-e/db";
@@ -14,13 +13,12 @@ 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();
// Entity types that support comments. Keep in sync with entityWorkspaceLookups.
const entityTypeEnum = z.enum(["task", "project", "habit", "note", "canvas", "daily_note", "calendar_event"]);
const entityTypeEnum = z.enum(["task", "project", "habit", "note", "daily_note", "calendar_event"]);
interface EntityWorkspaceLookup {
table: AnyPgTable;
@@ -35,7 +33,6 @@ const entityWorkspaceLookups: Record<string, EntityWorkspaceLookup> = {
project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId },
habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId },
note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId },
canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId },
daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId },
calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId },
};
@@ -151,23 +148,6 @@ 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) {
+11 -67
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, statusDefinitions } from "@project-e/db";
import { and, asc, desc, eq, ilike, inArray, isNull, or } from "drizzle-orm";
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 { recordActivity } from "../middleware/activity";
export const mcpRoutes = new Hono();
@@ -81,8 +81,7 @@ const tools: ToolDefinition[] = [
type: "object",
properties: {
domain_id: { type: "string", description: "Workspace/domain ID" },
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" },
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
project_id: { type: "string" },
search: { type: "string" },
@@ -96,14 +95,7 @@ const tools: ToolDefinition[] = [
eq(tasks.domainId, params.domain_id as string),
isNull(tasks.deletedAt),
];
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.status) conditions.push(eq(tasks.status, params.status as any));
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}%`));
@@ -127,7 +119,7 @@ const tools: ToolDefinition[] = [
domain_id: { type: "string", description: "Workspace/domain ID" },
title: { type: "string" },
description: { type: "string" },
status_id: { type: "string", description: "Status definition ID (defaults to the project's default status)" },
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
due_date: { type: "string" },
project_id: { type: "string" },
@@ -135,33 +127,10 @@ 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,
statusId,
completedAt,
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,
@@ -173,7 +142,7 @@ const tools: ToolDefinition[] = [
action: "created",
entityType: "task",
entityId: task.id,
changes: { title: task.title, statusId: task.statusId },
changes: { title: task.title, status: task.status },
workspaceId: params.domain_id as string,
});
@@ -189,7 +158,7 @@ const tools: ToolDefinition[] = [
task_id: { type: "string" },
title: { type: "string" },
description: { type: "string" },
status_id: { type: "string", description: "Status definition ID (must belong to the task's project)" },
status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] },
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
due_date: { type: "string" },
},
@@ -203,19 +172,7 @@ 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_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.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();
@@ -279,21 +236,8 @@ 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({ statusId: doneStatus.id, completedAt: new Date(), updatedAt: new Date() })
.set({ status: "done", completedAt: new Date(), updatedAt: new Date() })
.where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt)))
.returning();
@@ -622,7 +566,7 @@ const tools: ToolDefinition[] = [
const results: Record<string, unknown[]> = {};
if (types.includes("tasks")) {
results.tasks = await db.select({ id: tasks.id, title: tasks.title, statusId: tasks.statusId, priority: tasks.priority }).from(tasks)
results.tasks = await db.select({ id: tasks.id, title: tasks.title, status: tasks.status, priority: tasks.priority }).from(tasks)
.where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`))).limit(limit);
}
if (types.includes("notes")) {
+28 -154
View File
@@ -1,170 +1,44 @@
import { Hono } from "hono";
import { db, notifications } from "@project-e/db";
import { and, count, desc, eq, isNull } from "drizzle-orm";
import { requireAuth, createErrorResponse, AuthError, isUuid } from "../middleware/auth";
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";
export const notificationRoutes = new Hono();
// 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.
// 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;
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.
// GET /api/notifications?workspace_id=&limit= — Recent activity for a workspace.
notificationRoutes.get("/", async (c) => {
try {
const user = await requireAuth(c);
const workspaceId = c.req.query("workspace_id");
const unreadOnly = c.req.query("unread") === "true";
let workspaceId = c.req.query("workspace_id");
if (!workspaceId) {
const active = await resolveActiveDomain(user);
workspaceId = active.id;
}
await requireWorkspaceAccess(c, workspaceId);
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 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 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))),
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 totalItems = Number(totalResult[0]?.value ?? 0);
return c.json({
items,
totalItems,
unreadCount: Number(unreadResult[0]?.value ?? 0),
page,
perPage: effectiveLimit,
limit: effectiveLimit,
offset,
});
return c.json({ items, count: Number(countResult[0]?.count || 0) });
} 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(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);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get notifications" } }, 500);
}
});
+7 -53
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed, statusDefinitions } from "@project-e/db";
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 { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
@@ -10,16 +10,6 @@ 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(),
@@ -123,10 +113,9 @@ projectRoutes.get("/", async (c) => {
const totalItems = Number(countResult[0]?.count || 0);
// Fetch task counts, status definitions and tags for all projects
// Fetch task counts 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);
@@ -147,32 +136,15 @@ 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)
.innerJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(
eq(tasks.projectId, projectId),
eq(statusDefinitions.category, "done"),
isNull(tasks.deletedAt),
));
.where(and(eq(tasks.projectId, projectId), eq(tasks.status, "done"), isNull(tasks.deletedAt)));
taskCountMap.set(projectId, {
total: Number(totalResult?.count || 0),
@@ -186,7 +158,6 @@ 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,
@@ -233,11 +204,6 @@ 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 }))
@@ -255,7 +221,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, statuses: seededStatuses }, 201);
return c.json(project, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
@@ -294,22 +260,11 @@ projectRoutes.get("/:id", async (c) => {
.where(eq(sections.projectId, id))
.orderBy(asc(sections.sortOrder));
// 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()
// Fetch tasks
const projectTasks = await 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({
@@ -322,14 +277,13 @@ projectRoutes.get("/:id", async (c) => {
.where(eq(projectTags.projectId, id));
const totalTasks = projectTasks.length;
const completedTasks = projectTasks.filter((t) => t.status?.category === "done").length;
const completedTasks = projectTasks.filter(t => t.status === "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
@@ -1,389 +0,0 @@
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);
}
});
+45 -230
View File
@@ -1,60 +1,21 @@
import { Hono } from "hono";
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 { 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 { 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 STATUS_CATEGORIES = ["todo", "in_progress", "done", "cancelled"] as const;
const taskStatusEnum = z.enum(["todo", "in_progress", "done", "cancelled"]);
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(),
statusId: z.string().uuid().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(),
@@ -66,12 +27,13 @@ const createTaskSchema = z.object({
customFields: z.record(z.string(), z.unknown()).optional(),
recurrenceRule: z.string().optional().nullable(),
tagIds: z.array(z.string().uuid()).optional(),
tagNames: z.array(z.string().min(1)).optional(),
});
const updateTaskSchema = z.object({
title: z.string().min(1).optional(),
description: z.string().optional().nullable(),
statusId: z.string().uuid().optional().nullable(),
status: taskStatusEnum.optional(),
priority: taskPriorityEnum.optional(),
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
@@ -150,16 +112,8 @@ taskRoutes.get("/", async (c) => {
];
if (status) {
// 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));
const statuses = status.split(",");
conditions.push(inArray(tasks.status, statuses as any));
}
if (priority) {
const priorities = priority.split(",");
@@ -211,7 +165,7 @@ taskRoutes.get("/", async (c) => {
created: tasks.createdAt,
updated: tasks.updatedAt,
title: tasks.title,
status: statusDefinitions.sortOrder,
status: tasks.status,
priority: tasks.priority,
order: tasks.order,
due_date: tasks.dueDate,
@@ -222,18 +176,15 @@ 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({ ...taskColumns, status: statusColumns })
db.select()
.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)),
]);
@@ -343,47 +294,39 @@ 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,
statusId,
status: data.status,
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 ?? {},
recurrenceRule: data.recurrenceRule ?? null,
}).returning();
if (data.tagIds && data.tagIds.length > 0) {
const tagIdsToLink: string[] = [...(data.tagIds || [])];
if ((data as any).tagNames && (data as any).tagNames.length > 0) {
for (const rawName of (data as any).tagNames) {
const name = rawName.trim().toLowerCase();
if (!name) continue;
let [existing] = await db.select({ id: tagsTable.id }).from(tagsTable).where(eq(tagsTable.name, name)).limit(1);
if (!existing) {
const [created] = await db.insert(tagsTable).values({ name, scope: "tasks" as any }).returning({ id: tagsTable.id });
existing = created;
}
if (existing && !tagIdsToLink.includes(existing.id)) tagIdsToLink.push(existing.id);
}
}
if (tagIdsToLink.length > 0) {
await db.insert(taskTags).values(
data.tagIds.map(tagId => ({ taskId: task.id, tagId }))
);
tagIdsToLink.map(tagId => ({ taskId: task.id, tagId }))
).onConflictDoNothing();
}
await recordActivity({
@@ -391,40 +334,16 @@ taskRoutes.post("/", async (c) => {
action: "created",
entityType: "task",
entityId: task.id,
changes: { title: task.title, statusId: task.statusId, priority: task.priority },
changes: { title: task.title, status: task.status, 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) {
@@ -512,10 +431,8 @@ taskRoutes.get("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const taskColumns = getTableColumns(tasks);
const [task] = await db.select({ ...taskColumns, status: statusColumns })
const [task] = await db.select()
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(eq(tasks.id, id), isNull(tasks.deletedAt)))
.limit(1);
@@ -526,9 +443,8 @@ taskRoutes.get("/:id", async (c) => {
await requireWorkspaceAccess(c, task.domainId);
// Fetch subtasks
const subtasks = await db.select({ ...taskColumns, status: statusColumns })
const subtasks = await db.select()
.from(tasks)
.leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id))
.where(and(eq(tasks.parentId, id), isNull(tasks.deletedAt)))
.orderBy(asc(tasks.order));
@@ -546,24 +462,20 @@ taskRoutes.get("/:id", async (c) => {
const depRows = await db.select({
id: tasks.id,
title: tasks.title,
statusId: tasks.statusId,
status: statusColumns,
status: tasks.status,
})
.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,
statusId: tasks.statusId,
status: statusColumns,
status: tasks.status,
})
.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({
@@ -627,6 +539,7 @@ 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;
@@ -636,31 +549,6 @@ 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)
@@ -673,44 +561,16 @@ taskRoutes.patch("/:id", async (c) => {
action: "updated",
entityType: "task",
entityId: id,
changes: { ...data, previousStatusId: existing.statusId },
changes: { ...data, previousStatus: existing.status },
workspaceId: existing.domainId,
});
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);
}
}
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } });
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) {
@@ -992,7 +852,7 @@ taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
}
});
// POST /api/tasks/:id/status — Change task status (Kanban drag / completion)
// POST /api/tasks/:id/status — Change task status (Kanban drag)
taskRoutes.post("/:id/status", async (c) => {
try {
const user = await requireAuth(c);
@@ -1001,8 +861,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 { statusId } = z.object({
statusId: z.string().uuid("Invalid status id"),
const { status: newStatus } = z.object({
status: taskStatusEnum,
}).parse(body);
const [existing] = await db.select()
@@ -1016,33 +876,13 @@ 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> = {
statusId,
completedAt: isDone ? new Date() : null,
status: newStatus,
updatedAt: new Date(),
};
if (newStatus === "done") {
updateValues.completedAt = new Date();
}
const [updated] = await db.update(tasks)
.set(updateValues)
@@ -1051,39 +891,14 @@ taskRoutes.post("/:id/status", async (c) => {
await recordActivity({
actor: user.name,
action: isDone ? "completed" : "updated",
action: newStatus === "done" ? "completed" : "updated",
entityType: "task",
entityId: id,
changes: { previousStatusId: existing.statusId, newStatusId: statusId, newStatusLabel: status.label },
changes: { previousStatus: existing.status, newStatus },
workspaceId: existing.domainId,
});
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 });
return c.json(updated);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
-118
View File
@@ -1,118 +0,0 @@
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);
}
});