refactor: remove canvas, automations, and custom statuses; simplify notification and status model
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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")) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user