diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 023d620..8f364a0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -27,6 +27,9 @@ import { analyticsRoutes } from "./routes/analytics"; import { activityRoutes } from "./routes/activity"; import { importExportRoutes } from "./routes/import-export"; import { notificationRoutes } from "./routes/notifications"; +import { moduleRoutes } from "./routes/modules"; +import { cycleRoutes } from "./routes/cycles"; +import { linkRoutes } from "./routes/links"; import { healthHandler } from "./routes/health"; const app = new Hono(); @@ -45,6 +48,10 @@ app.get("/api/health", async (c) => { // Routes app.route("/api/auth", authRoutes); app.route("/api/domains", domainRoutes); +app.route("/api/projects/:projectId/modules", moduleRoutes); +app.route("/api/modules", moduleRoutes); +app.route("/api/projects/:projectId/cycles", cycleRoutes); +app.route("/api/cycles", cycleRoutes); app.route("/api/tasks", taskRoutes); app.route("/api/habits", habitRoutes); app.route("/api/projects", projectRoutes); @@ -64,6 +71,7 @@ app.route("/api/error-log", errorLogRoutes); app.route("/api/analytics", analyticsRoutes); app.route("/api/activity", activityRoutes); app.route("/api/notifications", notificationRoutes); +app.route("/api/links", linkRoutes); app.route("/api", importExportRoutes); app.route("/api", realtimeRoutes); app.route("/api/mcp", mcpRoutes); diff --git a/apps/api/src/lib/automation-engine.ts b/apps/api/src/lib/automation-engine.ts deleted file mode 100644 index b8ee784..0000000 --- a/apps/api/src/lib/automation-engine.ts +++ /dev/null @@ -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; - /** Diff of the mutation, e.g. { previousStatusId } for status changes. */ - changes?: Record; - /** 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; - statusById: Map; - idByKey: Map; - categoryById: Map; -} - -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 }, - ctx: RuleContext, - actor: string -): Promise { - 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 { - 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(); - const categoryById = new Map(); - 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); - } -} diff --git a/apps/api/src/lib/notify.ts b/apps/api/src/lib/notify.ts deleted file mode 100644 index 523950b..0000000 --- a/apps/api/src/lib/notify.ts +++ /dev/null @@ -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 { - 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): Promise { - 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 }); -} diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index e0c5b66..fc91656 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { db, tasks, habits, habitCompletions, projects } from "@project-e/db"; -import { and, eq, gte, inArray, isNull, or } from "drizzle-orm"; +import { and, eq, gte, inArray, isNotNull, isNull, or } from "drizzle-orm"; import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; export const analyticsRoutes = new Hono(); @@ -30,7 +30,7 @@ analyticsRoutes.get("/productivity", async (c) => { isNull(tasks.deletedAt), )); - const completedTasks = allTasks.filter(t => t.status === "done"); + const completedTasks = allTasks.filter(t => t.completedAt !== null); const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0; return c.json({ @@ -165,7 +165,7 @@ analyticsRoutes.get("/projects", async (c) => { // Count tasks per project (any status, including non-done) for the domain const taskRows = projectIds.length > 0 - ? await db.select({ projectId: tasks.projectId, status: tasks.status }) + ? await db.select({ projectId: tasks.projectId, completedAt: tasks.completedAt }) .from(tasks) .where(and( isNull(tasks.deletedAt), @@ -178,7 +178,7 @@ analyticsRoutes.get("/projects", async (c) => { if (!t.projectId) continue; const entry = counts.get(t.projectId) ?? { totalTasks: 0, completedTasks: 0 }; entry.totalTasks += 1; - if (t.status === "done") entry.completedTasks += 1; + if (t.completedAt) entry.completedTasks += 1; counts.set(t.projectId, entry); } @@ -261,7 +261,7 @@ analyticsRoutes.get("/cycle", async (c) => { await requireWorkspaceAccess(c, domainId); const startDate = new Date(); startDate.setDate(startDate.getDate() - range); - const doneTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), eq(tasks.status, "done"), gte(tasks.completedAt, startDate), isNull(tasks.deletedAt))); + const doneTasks = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNotNull(tasks.completedAt), gte(tasks.completedAt, startDate), isNull(tasks.deletedAt))); const durations: number[] = []; for (const t of doneTasks) if (t.completedAt) durations.push((t.completedAt.getTime() - t.createdAt.getTime()) / (1000*60*60*24)); durations.sort((a,b)=>a-b); @@ -319,7 +319,7 @@ analyticsRoutes.get("/daily", async (c) => { for (const t of domainTasks) { const createdKey = localDateKey(t.createdAt); createdByDay.set(createdKey, (createdByDay.get(createdKey) || 0) + 1); - if (t.status === "done" && t.completedAt) { + if (t.completedAt) { const completedKey = localDateKey(t.completedAt); completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1); } diff --git a/apps/api/src/routes/automations.ts b/apps/api/src/routes/automations.ts deleted file mode 100644 index 53179f9..0000000 --- a/apps/api/src/routes/automations.ts +++ /dev/null @@ -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 | 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 = {}; - 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); - } -}); diff --git a/apps/api/src/routes/cycles.ts b/apps/api/src/routes/cycles.ts new file mode 100644 index 0000000..178ad3c --- /dev/null +++ b/apps/api/src/routes/cycles.ts @@ -0,0 +1,551 @@ +import { Hono } from "hono"; +import { db, cycles, tasks, projects } from "@project-e/db"; +import { and, asc, desc, eq, ilike, inArray, 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 cycleRoutes = new Hono(); + +const createCycleSchema = z.object({ + projectId: z.string().uuid("Invalid project id"), + name: z.string().min(1, "Name is required"), + startDate: z.string().datetime().optional().nullable(), + endDate: z.string().datetime().optional().nullable(), + active: z.boolean().optional().default(true), +}); + +const updateCycleSchema = z.object({ + name: z.string().min(1).optional(), + startDate: z.string().datetime().optional().nullable(), + endDate: z.string().datetime().optional().nullable(), + active: z.boolean().optional(), +}); + +// GET / — List cycles for a project +cycleRoutes.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 limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); + const offset = parseInt(url.searchParams.get("offset") || "0"); + const search = url.searchParams.get("search"); + const active = url.searchParams.get("active"); + const sort = url.searchParams.get("sort") || "-created"; + + const projectId = url.searchParams.get("projectId") || url.searchParams.get("project_id"); + if (!projectId || !isUuid(projectId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "projectId query parameter is required" } }, 400); + } + + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + const conditions: any[] = [eq(cycles.projectId, projectId)]; + + if (search) { + conditions.push(ilike(cycles.name, `%${search}%`)); + } + if (active === "true" || active === "false") { + conditions.push(eq(cycles.active, active === "true")); + } + + const sortDir = sort.startsWith("-") ? "desc" : "asc"; + const sortField = sort.replace(/^-/, ""); + const sortColumns: Record = { + created: cycles.createdAt, + updated: cycles.updatedAt, + name: cycles.name, + active: cycles.active, + start_date: cycles.startDate, + end_date: cycles.endDate, + created_at: cycles.createdAt, + updated_at: cycles.updatedAt, + }; + const orderColumn = sortDir === "asc" + ? asc(sortColumns[sortField] || cycles.createdAt) + : desc(sortColumns[sortField] || cycles.createdAt); + + const [items, countResult] = await Promise.all([ + db.select() + .from(cycles) + .where(and(...conditions)) + .orderBy(orderColumn) + .limit(limit) + .offset(offset || (page - 1) * limit), + db.select({ count: sql`count(*)` }) + .from(cycles) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + return c.json({ + items, + totalItems, + totalPages: Math.ceil(totalItems / limit), + page, + perPage: limit, + limit, + offset: offset || (page - 1) * limit, + }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[cycles] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list cycles" } }, 500); + } +}); + +// POST / — Create a cycle +cycleRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createCycleSchema.parse(body); + + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, data.projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + const [cycle] = await db.insert(cycles).values({ + name: data.name, + projectId: data.projectId, + startDate: data.startDate ? new Date(data.startDate) : null, + endDate: data.endDate ? new Date(data.endDate) : null, + active: data.active ?? true, + }).returning(); + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "cycle", + entityId: cycle.id, + changes: { name: cycle.name, active: cycle.active, projectId: data.projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "cycle.created", entityType: "cycle", entityId: cycle.id, data: { name: cycle.name, projectId: data.projectId } }); + + return c.json(cycle, 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("[cycles] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create cycle" } }, 500); + } +}); + +// GET /:id — Get a single cycle +cycleRoutes.get("/: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 [cycle] = await db.select() + .from(cycles) + .where(eq(cycles.id, id)) + .limit(1); + + if (!cycle) { + return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, cycle.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const cycleTasks = await db.select() + .from(tasks) + .where(and(eq(tasks.cycleId, id), isNull(tasks.deletedAt))) + .orderBy(asc(tasks.order)); + + return c.json({ ...cycle, tasks: cycleTasks }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[cycles] GET/:id error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get cycle" } }, 500); + } +}); + +// PATCH /:id — Update a cycle +cycleRoutes.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 = updateCycleSchema.parse(body); + + const [existing] = await db.select() + .from(cycles) + .where(eq(cycles.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const updateValues: Record = {}; + if (data.name !== undefined) updateValues.name = data.name; + if (data.startDate !== undefined) updateValues.startDate = data.startDate ? new Date(data.startDate) : null; + if (data.endDate !== undefined) updateValues.endDate = data.endDate ? new Date(data.endDate) : null; + if (data.active !== undefined) updateValues.active = data.active; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(cycles) + .set(updateValues) + .where(eq(cycles.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: "updated", + entityType: "cycle", + entityId: id, + changes: { ...data, previousName: existing.name }, + workspaceId: project?.domainId || "", + }); + + await enqueueWebhooks({ workspaceId: project?.domainId || "", event: "cycle.updated", entityType: "cycle", entityId: id, data: { ...data } }); + + 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("[cycles] PATCH error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update cycle" } }, 500); + } +}); + +// DELETE /:id — Delete a cycle (clears cycleId on tasks) +cycleRoutes.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(cycles) + .where(eq(cycles.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.projectId)) + .limit(1); + + const workspaceId = project?.domainId || ""; + await requireWorkspaceAccess(c, workspaceId); + + // Clear cycleId on tasks belonging to this cycle + await db.update(tasks) + .set({ cycleId: null, updatedAt: new Date() }) + .where(eq(tasks.cycleId, id)); + + await db.delete(cycles).where(eq(cycles.id, id)); + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "cycle", + entityId: id, + changes: { name: existing.name, projectId: existing.projectId }, + workspaceId, + }); + + await enqueueWebhooks({ workspaceId, event: "cycle.deleted", entityType: "cycle", 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("[cycles] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete cycle" } }, 500); + } +}); + +// POST /:id/tasks — Add a task to this cycle +cycleRoutes.post("/:id/tasks", async (c) => { + try { + const user = await requireAuth(c); + const cycleId = c.req.param("id"); + if (!isUuid(cycleId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + const body = await c.req.json(); + const { taskId } = z.object({ taskId: z.string().uuid("Invalid task id") }).parse(body); + + const [cycle] = await db.select() + .from(cycles) + .where(eq(cycles.id, cycleId)) + .limit(1); + + if (!cycle) { + return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, cycle.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + // Single-cycle constraint: if the task is already in another cycle, remove it first + if (task.cycleId && task.cycleId !== cycleId) { + await db.update(tasks) + .set({ cycleId: null, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + } + + // Assign task to this cycle + await db.update(tasks) + .set({ cycleId, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + + await recordActivity({ + actor: user.name, + action: "added_task", + entityType: "cycle", + entityId: cycleId, + changes: { taskId, taskTitle: task.title, previousCycleId: task.cycleId }, + workspaceId: project?.domainId || "", + }); + + return c.json({ success: true }, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[cycles] POST /:id/tasks error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add task to cycle" } }, 500); + } +}); + +// DELETE /:id/tasks/:taskId — Remove a task from this cycle +cycleRoutes.delete("/:id/tasks/:taskId", async (c) => { + try { + const user = await requireAuth(c); + const cycleId = c.req.param("id"); + const taskId = c.req.param("taskId"); + if (!isUuid(cycleId) || !isUuid(taskId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + + const [cycle] = await db.select() + .from(cycles) + .where(eq(cycles.id, cycleId)) + .limit(1); + + if (!cycle) { + return c.json({ error: { code: "NOT_FOUND", message: "Cycle not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, cycle.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + if (task.cycleId !== cycleId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Task is not in this cycle" } }, 400); + } + + await db.update(tasks) + .set({ cycleId: null, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + + await recordActivity({ + actor: user.name, + action: "removed_task", + entityType: "cycle", + entityId: cycleId, + changes: { taskId, taskTitle: task.title }, + workspaceId: project?.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("[cycles] DELETE /:id/tasks/:taskId error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove task from cycle" } }, 500); + } +}); + +// POST /:id/transfer — Move tasks from this cycle to a target cycle +cycleRoutes.post("/:id/transfer", async (c) => { + try { + const user = await requireAuth(c); + const sourceCycleId = c.req.param("id"); + if (!isUuid(sourceCycleId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + const body = await c.req.json(); + const { target_cycle_id, task_ids } = z.object({ + target_cycle_id: z.string().uuid("Invalid target cycle id"), + task_ids: z.array(z.string().uuid("Invalid task id")).min(1, "At least one task id is required"), + }).parse(body); + + const [sourceCycle] = await db.select() + .from(cycles) + .where(eq(cycles.id, sourceCycleId)) + .limit(1); + + if (!sourceCycle) { + return c.json({ error: { code: "NOT_FOUND", message: "Source cycle not found" } }, 404); + } + + const [targetCycle] = await db.select() + .from(cycles) + .where(eq(cycles.id, target_cycle_id)) + .limit(1); + + if (!targetCycle) { + return c.json({ error: { code: "NOT_FOUND", message: "Target cycle not found" } }, 404); + } + + if (sourceCycle.projectId !== targetCycle.projectId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Target cycle must be in the same project" } }, 400); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, sourceCycle.projectId)) + .limit(1); + + const workspaceId = project?.domainId || ""; + await requireWorkspaceAccess(c, workspaceId); + + // Validate all tasks exist, are not deleted, and belong to the source cycle + const taskRows = await db.select() + .from(tasks) + .where(and(inArray(tasks.id, task_ids), isNull(tasks.deletedAt))); + + if (taskRows.length !== task_ids.length) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "One or more tasks not found or are deleted" } }, 400); + } + + const nonMemberTasks = taskRows.filter((t) => t.cycleId !== sourceCycleId); + if (nonMemberTasks.length > 0) { + return c.json({ + error: { + code: "VALIDATION_ERROR", + message: "One or more tasks do not belong to the source cycle", + details: { task_ids: nonMemberTasks.map((t) => t.id) }, + }, + }, 400); + } + + // Move all tasks to the target cycle + await db.update(tasks) + .set({ cycleId: target_cycle_id, updatedAt: new Date() }) + .where(inArray(tasks.id, task_ids)); + + await recordActivity({ + actor: user.name, + action: "transferred_tasks", + entityType: "cycle", + entityId: sourceCycleId, + changes: { + targetCycleId: target_cycle_id, + taskIds: task_ids, + count: task_ids.length, + }, + workspaceId, + }); + + await enqueueWebhooks({ + workspaceId, + event: "cycle.tasks_transferred", + entityType: "cycle", + entityId: sourceCycleId, + data: { targetCycleId: target_cycle_id, taskIds: task_ids, count: task_ids.length }, + }); + + return c.json({ success: true, transferred: task_ids.length }); + } 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("[cycles] POST /:id/transfer error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to transfer tasks" } }, 500); + } +}); diff --git a/apps/api/src/routes/graph.ts b/apps/api/src/routes/graph.ts index ceba2b5..54af011 100644 --- a/apps/api/src/routes/graph.ts +++ b/apps/api/src/routes/graph.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { db, domains, notes, tasks, habits, projects, sections, tags as tagsTable, links } from "@project-e/db"; -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { z } from "zod"; @@ -57,11 +57,11 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg for (const s of sectionRows) addNode(s.id, s.name, 'section'); for (const t of tagRows) addNode(t.id, t.name, 'tag'); - // Read links from the canonical links table - const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id)]; + // Read links from the canonical links table (both directions) + const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id), ...projectRows.map(p => p.id), ...sectionRows.map(s => s.id)]; if (allIds.length > 0) { const linkRows = await db.select().from(links) - .where(inArray(links.sourceId, allIds)); + .where(or(inArray(links.sourceId, allIds), inArray(links.targetId, allIds))); for (const l of linkRows) addEdge(l.sourceId, l.targetId, l.linkType); } diff --git a/apps/api/src/routes/links.ts b/apps/api/src/routes/links.ts new file mode 100644 index 0000000..fead6b8 --- /dev/null +++ b/apps/api/src/routes/links.ts @@ -0,0 +1,173 @@ +import { Hono } from "hono"; +import { db, links, tasks, notes, projects } from "@project-e/db"; +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { z } from "zod"; + +export const linkRoutes = new Hono(); + +const createLinkSchema = z.object({ + sourceType: z.string().min(1), + sourceId: z.string().uuid(), + targetType: z.string().min(1), + targetId: z.string().uuid(), + linkType: z.enum(["relates", "blocks", "parent-child", "created-from"]), + direction: z.string().optional().nullable(), +}); + +async function resolveWorkspaceId(entityType: string, entityId: string): Promise { + if (entityType === "task") { + const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, entityId)).limit(1); + return row?.domainId ?? null; + } + if (entityType === "note") { + const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, entityId)).limit(1); + return row?.domainId ?? null; + } + return null; +} + +// GET /api/links — List links for an entity (either source OR target) +linkRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const entityType = url.searchParams.get("entityType"); + const entityId = url.searchParams.get("entityId"); + + if (!entityType || !entityId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "entityType and entityId query parameters are required" } }, 400); + } + + const workspaceId = await resolveWorkspaceId(entityType, entityId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + + const items = await db.select() + .from(links) + .where(or( + and(eq(links.sourceType, entityType), eq(links.sourceId, entityId)), + and(eq(links.targetType, entityType), eq(links.targetId, entityId)), + )); + + return c.json({ items }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[links] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list links" } }, 500); + } +}); + +// POST /api/links — Create a link between two entities +linkRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createLinkSchema.parse(body); + + // Prevent self-links + if (data.sourceId === data.targetId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Cannot link an entity to itself" } }, 400); + } + + // Check for duplicate link + const [existing] = await db.select({ id: links.id }) + .from(links) + .where(and( + eq(links.sourceId, data.sourceId), + eq(links.targetId, data.targetId), + eq(links.linkType, data.linkType), + )) + .limit(1); + + if (existing) { + return c.json({ error: { code: "CONFLICT", message: "Link already exists" } }, 409); + } + + const workspaceId = await resolveWorkspaceId(data.sourceType, data.sourceId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + + const [link] = await db.insert(links).values({ + sourceType: data.sourceType, + sourceId: data.sourceId, + targetType: data.targetType, + targetId: data.targetId, + linkType: data.linkType, + direction: data.direction ?? null, + }).returning(); + + if (workspaceId) { + await recordActivity({ + actor: user.name, + action: "created", + entityType: "link", + entityId: link.id, + changes: { sourceType: data.sourceType, sourceId: data.sourceId, targetType: data.targetType, targetId: data.targetId, linkType: data.linkType }, + workspaceId, + }); + } + + return c.json(link, 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("[links] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create link" } }, 500); + } +}); + +// DELETE /api/links/:id — Remove a link +linkRoutes.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(links) + .where(eq(links.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Link not found" } }, 404); + } + + const workspaceId = await resolveWorkspaceId(existing.sourceType, existing.sourceId); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + + await db.delete(links).where(eq(links.id, id)); + + if (workspaceId) { + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "link", + entityId: id, + changes: { sourceType: existing.sourceType, sourceId: existing.sourceId, targetType: existing.targetType, targetId: existing.targetId }, + workspaceId, + }); + } + + 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("[links] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete link" } }, 500); + } +}); diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index 0cacc62..db05fb0 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -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, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; -import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"; +import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, states as statesTable, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; +import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm"; import { recordActivity } from "../middleware/activity"; export const mcpRoutes = new Hono(); @@ -81,7 +81,7 @@ const tools: ToolDefinition[] = [ type: "object", properties: { domain_id: { type: "string", description: "Workspace/domain ID" }, - status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + state_group: { type: "string", enum: ["backlog", "unstarted", "started", "completed", "cancelled"], description: "Filter by workflow state group" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, project_id: { type: "string" }, search: { type: "string" }, @@ -95,7 +95,16 @@ const tools: ToolDefinition[] = [ eq(tasks.domainId, params.domain_id as string), isNull(tasks.deletedAt), ]; - // TODO(phase-2): filter by state_group / state_id instead of old status + if (params.state_group) { + const groups = (params.state_group as string).split(",") as any[]; + conditions.push( + exists( + db.select({ one: sql`1` }) + .from(statesTable) + .where(and(eq(statesTable.id, tasks.stateId), inArray(statesTable.group, groups))) + ) + ); + } if (params.priority) conditions.push(eq(tasks.priority, params.priority as any)); if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string)); if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`)); @@ -119,7 +128,6 @@ const tools: ToolDefinition[] = [ domain_id: { type: "string", description: "Workspace/domain ID" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, project_id: { type: "string" }, @@ -157,7 +165,6 @@ const tools: ToolDefinition[] = [ task_id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, }, diff --git a/apps/api/src/routes/modules.ts b/apps/api/src/routes/modules.ts new file mode 100644 index 0000000..26338a4 --- /dev/null +++ b/apps/api/src/routes/modules.ts @@ -0,0 +1,467 @@ +import { Hono } from "hono"; +import { db, modules, tasks, projects } from "@project-e/db"; +import { and, asc, desc, eq, ilike, inArray, 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 moduleRoutes = new Hono(); + +const moduleStatusEnum = z.enum(["planned", "in_progress", "completed", "cancelled"]); + +const createModuleSchema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional().nullable(), + status: moduleStatusEnum.optional().default("planned"), + startDate: z.string().datetime().optional().nullable(), + targetDate: z.string().datetime().optional().nullable(), + sortOrder: z.number().int().optional(), +}); + +const updateModuleSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().optional().nullable(), + status: moduleStatusEnum.optional(), + startDate: z.string().datetime().optional().nullable(), + targetDate: z.string().datetime().optional().nullable(), + sortOrder: z.number().int().optional(), +}); + +// GET / — List modules for a project +moduleRoutes.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 limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); + const offset = parseInt(url.searchParams.get("offset") || "0"); + const search = url.searchParams.get("search"); + const status = url.searchParams.get("status"); + const sort = url.searchParams.get("sort") || "-created"; + + const projectId = c.req.param("projectId") || url.searchParams.get("project_id"); + if (!projectId || !isUuid(projectId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "project_id is required" } }, 400); + } + + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + const conditions: any[] = [ + eq(modules.projectId, projectId), + isNull(modules.deletedAt), + ]; + + if (search) { + conditions.push(ilike(modules.name, `%${search}%`)); + } + if (status) { + const statuses = status.split(","); + conditions.push(inArray(modules.status, statuses as any)); + } + + const sortDir = sort.startsWith("-") ? "desc" : "asc"; + const sortField = sort.replace(/^-/, ""); + const sortColumns: Record = { + created: modules.createdAt, + updated: modules.updatedAt, + name: modules.name, + status: modules.status, + sort_order: modules.sortOrder, + created_at: modules.createdAt, + updated_at: modules.updatedAt, + }; + const orderColumn = sortDir === "asc" + ? asc(sortColumns[sortField] || modules.createdAt) + : desc(sortColumns[sortField] || modules.createdAt); + + const [items, countResult] = await Promise.all([ + db.select() + .from(modules) + .where(and(...conditions)) + .orderBy(orderColumn) + .limit(limit) + .offset(offset || (page - 1) * limit), + db.select({ count: sql`count(*)` }) + .from(modules) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + return c.json({ + items, + totalItems, + totalPages: Math.ceil(totalItems / limit), + page, + perPage: limit, + limit, + offset: offset || (page - 1) * limit, + }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[modules] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list modules" } }, 500); + } +}); + +// POST / — Create a module (projectId from URL path) +moduleRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createModuleSchema.parse(body); + + const projectId = c.req.param("projectId"); + if (!projectId || !isUuid(projectId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "project_id is required in URL path" } }, 400); + } + + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + + const [mod] = await db.insert(modules).values({ + name: data.name, + description: data.description ?? null, + projectId, + status: data.status, + startDate: data.startDate ? new Date(data.startDate) : null, + targetDate: data.targetDate ? new Date(data.targetDate) : null, + sortOrder: data.sortOrder ?? 0, + }).returning(); + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "module", + entityId: mod.id, + changes: { name: mod.name, status: mod.status, projectId }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ workspaceId: project.domainId, event: "module.created", entityType: "module", entityId: mod.id, data: { name: mod.name, projectId } }); + + return c.json(mod, 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("[modules] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create module" } }, 500); + } +}); + +// GET /:id — Get a single module +moduleRoutes.get("/: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 [mod] = await db.select() + .from(modules) + .where(and(eq(modules.id, id), isNull(modules.deletedAt))) + .limit(1); + + if (!mod) { + return c.json({ error: { code: "NOT_FOUND", message: "Module not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, mod.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const moduleTasks = await db.select() + .from(tasks) + .where(and(eq(tasks.moduleId, id), isNull(tasks.deletedAt))) + .orderBy(asc(tasks.order)); + + return c.json({ ...mod, tasks: moduleTasks }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[modules] GET/:id error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get module" } }, 500); + } +}); + +// PATCH /:id — Update a module +moduleRoutes.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 = updateModuleSchema.parse(body); + + const [existing] = await db.select() + .from(modules) + .where(and(eq(modules.id, id), isNull(modules.deletedAt))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Module not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const updateValues: Record = {}; + if (data.name !== undefined) updateValues.name = data.name; + if (data.description !== undefined) updateValues.description = data.description; + if (data.status !== undefined) updateValues.status = data.status; + if (data.startDate !== undefined) updateValues.startDate = data.startDate ? new Date(data.startDate) : null; + if (data.targetDate !== undefined) updateValues.targetDate = data.targetDate ? new Date(data.targetDate) : null; + if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(modules) + .set(updateValues) + .where(eq(modules.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: "updated", + entityType: "module", + entityId: id, + changes: { ...data, previousName: existing.name }, + workspaceId: project?.domainId || "", + }); + + await enqueueWebhooks({ workspaceId: project?.domainId || "", event: "module.updated", entityType: "module", entityId: id, data: { ...data } }); + + 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("[modules] PATCH error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update module" } }, 500); + } +}); + +// DELETE /:id — Soft delete a module (also clears moduleId on tasks) +moduleRoutes.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(modules) + .where(and(eq(modules.id, id), isNull(modules.deletedAt))) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "Module not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, existing.projectId)) + .limit(1); + + const workspaceId = project?.domainId || ""; + await requireWorkspaceAccess(c, workspaceId); + + // Clear moduleId on tasks belonging to this module + await db.update(tasks) + .set({ moduleId: null, updatedAt: new Date() }) + .where(eq(tasks.moduleId, id)); + + await db.update(modules) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(modules.id, id)); + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "module", + entityId: id, + changes: { name: existing.name, projectId: existing.projectId }, + workspaceId, + }); + + await enqueueWebhooks({ workspaceId, event: "module.deleted", entityType: "module", 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("[modules] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete module" } }, 500); + } +}); + +// POST /:id/tasks — Add a task to this module +moduleRoutes.post("/:id/tasks", async (c) => { + try { + const user = await requireAuth(c); + const moduleId = c.req.param("id"); + if (!isUuid(moduleId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + const body = await c.req.json(); + const { taskId } = z.object({ taskId: z.string().uuid("Invalid task id") }).parse(body); + + const [mod] = await db.select() + .from(modules) + .where(and(eq(modules.id, moduleId), isNull(modules.deletedAt))) + .limit(1); + + if (!mod) { + return c.json({ error: { code: "NOT_FOUND", message: "Module not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, mod.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + // Single-module constraint: if the task is already in another module, remove it first + if (task.moduleId && task.moduleId !== moduleId) { + await db.update(tasks) + .set({ moduleId: null, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + } + + // Assign task to this module + await db.update(tasks) + .set({ moduleId, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + + await recordActivity({ + actor: user.name, + action: "added_task", + entityType: "module", + entityId: moduleId, + changes: { taskId, taskTitle: task.title, previousModuleId: task.moduleId }, + workspaceId: project?.domainId || "", + }); + + return c.json({ success: true }, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[modules] POST /:id/tasks error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add task to module" } }, 500); + } +}); + +// DELETE /:id/tasks/:taskId — Remove a task from this module +moduleRoutes.delete("/:id/tasks/:taskId", async (c) => { + try { + const user = await requireAuth(c); + const moduleId = c.req.param("id"); + const taskId = c.req.param("taskId"); + if (!isUuid(moduleId) || !isUuid(taskId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + + const [mod] = await db.select() + .from(modules) + .where(and(eq(modules.id, moduleId), isNull(modules.deletedAt))) + .limit(1); + + if (!mod) { + return c.json({ error: { code: "NOT_FOUND", message: "Module not found" } }, 404); + } + + const [project] = await db.select({ domainId: projects.domainId }) + .from(projects) + .where(eq(projects.id, mod.projectId)) + .limit(1); + + await requireWorkspaceAccess(c, project?.domainId || ""); + + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, taskId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + if (task.moduleId !== moduleId) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Task is not in this module" } }, 400); + } + + await db.update(tasks) + .set({ moduleId: null, updatedAt: new Date() }) + .where(eq(tasks.id, taskId)); + + await recordActivity({ + actor: user.name, + action: "removed_task", + entityType: "module", + entityId: moduleId, + changes: { taskId, taskTitle: task.title }, + workspaceId: project?.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("[modules] DELETE /:id/tasks/:taskId error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove task from module" } }, 500); + } +}); diff --git a/apps/api/src/routes/statuses.ts b/apps/api/src/routes/statuses.ts deleted file mode 100644 index f5a30df..0000000 --- a/apps/api/src/routes/statuses.ts +++ /dev/null @@ -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`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`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 = {}; - 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`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); - } -}); \ No newline at end of file diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index 96aa63d..8e1a652 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db"; +import { db, tasks, states as statesTable, 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"; @@ -9,17 +9,16 @@ import { RRule } from "rrule"; export const taskRoutes = new Hono(); -const taskStatusEnum = z.enum(["todo", "in_progress", "done", "cancelled"]); const taskPriorityEnum = z.enum(["low", "medium", "high", "urgent"]); const createTaskSchema = z.object({ title: z.string().min(1, "Title is required"), description: z.string().optional().nullable(), - status: taskStatusEnum.optional().default("todo"), priority: taskPriorityEnum.optional().default("medium"), domain: z.string().min(1, "Domain is required"), projectId: z.string().uuid().optional().nullable(), sectionId: z.string().uuid().optional().nullable(), + stateId: z.string().uuid().optional().nullable(), parentId: z.string().uuid().optional().nullable(), dueDate: z.string().datetime().optional().nullable(), estimatedMinutes: z.number().int().positive().optional().nullable(), @@ -33,10 +32,10 @@ const createTaskSchema = z.object({ const updateTaskSchema = z.object({ title: z.string().min(1).optional(), description: z.string().optional().nullable(), - status: taskStatusEnum.optional(), priority: taskPriorityEnum.optional(), projectId: z.string().uuid().optional().nullable(), sectionId: z.string().uuid().optional().nullable(), + stateId: z.string().uuid().optional().nullable(), parentId: z.string().uuid().optional().nullable(), dueDate: z.string().datetime().optional().nullable(), estimatedMinutes: z.number().int().positive().optional().nullable(), @@ -86,7 +85,10 @@ taskRoutes.get("/", async (c) => { const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50"))); const filter = url.searchParams.get("filter") || undefined; const sort = url.searchParams.get("sort") || "-created"; - const status = url.searchParams.get("status"); + const stateId = url.searchParams.get("state_id"); + const stateGroup = url.searchParams.get("state_group"); + const moduleId = url.searchParams.get("module_id"); + const cycleId = url.searchParams.get("cycle_id"); const priority = url.searchParams.get("priority"); const tag = url.searchParams.get("tag"); const search = url.searchParams.get("search"); @@ -111,10 +113,6 @@ taskRoutes.get("/", async (c) => { isNull(tasks.deletedAt), ]; - if (status) { - const statuses = status.split(","); - conditions.push(inArray(tasks.status, statuses as any)); - } if (priority) { const priorities = priority.split(","); conditions.push(inArray(tasks.priority, priorities as any)); @@ -141,6 +139,25 @@ taskRoutes.get("/", async (c) => { if (sectionId) { conditions.push(eq(tasks.sectionId, sectionId)); } + if (stateId) { + conditions.push(eq(tasks.stateId, stateId)); + } + if (moduleId) { + conditions.push(eq(tasks.moduleId, moduleId)); + } + if (cycleId) { + conditions.push(eq(tasks.cycleId, cycleId)); + } + if (stateGroup) { + const groups = stateGroup.split(",") as any[]; + conditions.push( + exists( + db.select({ one: sql`1` }) + .from(statesTable) + .where(and(eq(statesTable.id, tasks.stateId), inArray(statesTable.group, groups))) + ) + ); + } // Tag filter applied in SQL (EXISTS on the junction table) so it runs over // the full dataset before pagination — filtering in-memory after fetching a // page would miss tasks beyond the limit and report a wrong totalItems. @@ -165,7 +182,6 @@ taskRoutes.get("/", async (c) => { created: tasks.createdAt, updated: tasks.updatedAt, title: tasks.title, - status: tasks.status, priority: tasks.priority, order: tasks.order, due_date: tasks.dueDate, @@ -294,20 +310,36 @@ taskRoutes.post("/", async (c) => { } } + // Validate stateId exists and compute completedAt + let completedAt: Date | null = null; + if (data.stateId) { + const [state] = await db.select({ id: statesTable.id, group: statesTable.group }) + .from(statesTable) + .where(eq(statesTable.id, data.stateId)) + .limit(1); + if (!state) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + if (state.group === "completed") { + completedAt = new Date(); + } + } + const [task] = await db.insert(tasks).values({ title: data.title, description: data.description ?? null, - status: data.status, priority: data.priority, domainId: data.domain, projectId: data.projectId ?? null, sectionId: data.sectionId ?? null, + stateId: data.stateId ?? null, parentId: data.parentId ?? null, dueDate: data.dueDate ? new Date(data.dueDate) : null, estimatedMinutes: data.estimatedMinutes ?? null, order: data.order ?? 0, customFields: data.customFields ?? {}, recurrenceRule: data.recurrenceRule ?? null, + completedAt, }).returning(); const tagIdsToLink: string[] = [...(data.tagIds || [])]; @@ -334,7 +366,7 @@ taskRoutes.post("/", async (c) => { action: "created", entityType: "task", entityId: task.id, - changes: { title: task.title, status: task.status, priority: task.priority }, + changes: { title: task.title, priority: task.priority }, workspaceId: data.domain, }); @@ -462,7 +494,6 @@ taskRoutes.get("/:id", async (c) => { const depRows = await db.select({ id: tasks.id, title: tasks.title, - status: tasks.status, }) .from(taskDependencies) .innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id)) @@ -472,7 +503,6 @@ taskRoutes.get("/:id", async (c) => { const dependentRows = await db.select({ id: tasks.id, title: tasks.title, - status: tasks.status, }) .from(taskDependencies) .innerJoin(tasks, eq(taskDependencies.taskId, tasks.id)) @@ -539,7 +569,6 @@ taskRoutes.patch("/:id", async (c) => { const updateValues: Record = {}; 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; @@ -549,6 +578,25 @@ 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; + + // Validate stateId and compute completedAt when state changes + if (data.stateId !== undefined) { + if (data.stateId !== null) { + const [state] = await db.select({ id: statesTable.id, group: statesTable.group }) + .from(statesTable) + .where(eq(statesTable.id, data.stateId)) + .limit(1); + if (!state) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + updateValues.stateId = data.stateId; + updateValues.completedAt = state.group === "completed" ? new Date() : null; + } else { + updateValues.stateId = null; + updateValues.completedAt = null; + } + } + updateValues.updatedAt = new Date(); const [updated] = await db.update(tasks) @@ -561,11 +609,11 @@ taskRoutes.patch("/:id", async (c) => { action: "updated", entityType: "task", entityId: id, - changes: { ...data, previousStatus: existing.status }, + changes: { ...data }, workspaceId: existing.domainId, }); - await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } }); + await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data } }); if (data.recurrenceRule !== undefined) { await syncScheduledJob(id, data.recurrenceRule); @@ -852,66 +900,7 @@ taskRoutes.delete("/:id/dependencies/:depId", async (c) => { } }); -// POST /api/tasks/:id/status — Change task status (Kanban drag) -taskRoutes.post("/:id/status", async (c) => { - try { - const user = await requireAuth(c); - const id = c.req.param("id"); - if (!isUuid(id)) { - return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); - } - const body = await c.req.json(); - const { status: newStatus } = z.object({ - status: taskStatusEnum, - }).parse(body); - - const [existing] = await db.select() - .from(tasks) - .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) - .limit(1); - - if (!existing) { - return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); - } - - await requireWorkspaceAccess(c, existing.domainId); - - const updateValues: Record = { - status: newStatus, - updatedAt: new Date(), - }; - if (newStatus === "done") { - updateValues.completedAt = new Date(); - } - - const [updated] = await db.update(tasks) - .set(updateValues) - .where(eq(tasks.id, id)) - .returning(); - - await recordActivity({ - actor: user.name, - action: newStatus === "done" ? "completed" : "updated", - entityType: "task", - entityId: id, - changes: { previousStatus: existing.status, newStatus }, - workspaceId: existing.domainId, - }); - - return c.json(updated); - } catch (error) { - if (error instanceof AuthError) { - return c.json({ error: { code: error.code, message: error.message } }, error.status as any); - } - if (error instanceof z.ZodError) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); - } - console.error("[tasks] POST /:id/status error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update task status" } }, 500); - } -}); - -// GET /api/tasks/:id/history — Status change log (from activity feed) +// GET /api/tasks/:id/history — State change log (from activity feed) taskRoutes.get("/:id/history", async (c) => { try { const user = await requireAuth(c); diff --git a/apps/api/src/routes/timeline.ts b/apps/api/src/routes/timeline.ts deleted file mode 100644 index ce11004..0000000 --- a/apps/api/src/routes/timeline.ts +++ /dev/null @@ -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(); - 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); - } -}); diff --git a/apps/web/src/components/automation-rule-builder.tsx b/apps/web/src/components/automation-rule-builder.tsx deleted file mode 100644 index 2cb469f..0000000 --- a/apps/web/src/components/automation-rule-builder.tsx +++ /dev/null @@ -1,528 +0,0 @@ -import { useState } from "react"; -import { toast } from "sonner"; -import { Plus, Trash2 } from "lucide-react"; -import { api, useApiMutation } from "@/lib/api"; -import type { - AutomationAction, - AutomationActionType, - AutomationCondition, - AutomationConditionField, - AutomationRule, - AutomationTriggerType, - Project, -} from "@/lib/types"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; - -// ── Shared metadata (also used by the Automations tab for summaries) ─────────── - -export const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string }[] = [ - { value: "task_status_changed", label: "Task status changed" }, - { value: "task_created", label: "Task created" }, - { value: "due_date_approaching", label: "Due date approaching" }, -]; - -export const ACTION_OPTIONS: { value: AutomationActionType; label: string }[] = [ - { value: "set_status", label: "Set status" }, - { value: "set_priority", label: "Set priority" }, - { value: "add_label", label: "Add label" }, - { value: "create_notification", label: "Send notification" }, -]; - -export const PRIORITY_OPTIONS: { value: string; label: string }[] = [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "urgent", label: "Urgent" }, -]; - -const CONDITION_FIELD_OPTIONS: { value: AutomationConditionField; label: string }[] = [ - { value: "status", label: "Status" }, - { value: "priority", label: "Priority" }, - { value: "label", label: "Label" }, -]; - -const STATUS_OPS: { value: string; label: string }[] = [ - { value: "to", label: "changes to" }, - { value: "from", label: "changes from" }, - { value: "eq", label: "is" }, -]; - -const PRIORITY_OPS: { value: string; label: string }[] = [ - { value: "eq", label: "is" }, - { value: "neq", label: "is not" }, -]; - -const LABEL_OPS: { value: string; label: string }[] = [ - { value: "has", label: "has" }, - { value: "not_has", label: "does not have" }, -]; - -function opsForField(field: AutomationConditionField): { value: string; label: string }[] { - switch (field) { - case "status": - return STATUS_OPS; - case "priority": - return PRIORITY_OPS; - case "label": - return LABEL_OPS; - default: - return STATUS_OPS; - } -} - -function statusLabel(project: Project, key: string): string { - return project.statuses?.find((s) => s.key === key)?.label ?? key; -} - -function summarizeActions(project: Project, actions: AutomationAction[]): string[] { - return actions.map((action) => { - const params = action.params ?? {}; - switch (action.type) { - case "set_status": - return `Set status to ${statusLabel(project, String(params.statusKey ?? ""))}`; - case "set_priority": - return `Set priority to ${String(params.priority ?? "")}`; - case "add_label": - return `Add label "${String(params.label ?? "")}"`; - case "create_notification": - return `Notify: ${String(params.message ?? "")}`; - default: - return action.type; - } - }); -} - -export { summarizeActions }; - -// ── Rule builder dialog ───────────────────────────────────────────────────────── - -interface DraftCondition { - field: AutomationConditionField; - op: string; - value: string; -} - -interface DraftAction { - type: AutomationActionType; - params: Record; -} - -interface RuleBuilderProps { - project: Project; - open: boolean; - onOpenChange: (open: boolean) => void; - /** When set, the dialog edits this rule instead of creating a new one. */ - rule?: AutomationRule | null; - onSaved?: () => void; -} - -function toDraftConditions(conditions: AutomationCondition[]): DraftCondition[] { - return conditions.map((c) => ({ - field: c.field, - op: c.op, - value: typeof c.value === "string" ? c.value : String(c.value ?? ""), - })); -} - -function toDraftActions(actions: AutomationAction[]): DraftAction[] { - return actions.map((a) => { - const params: Record = {}; - for (const [key, value] of Object.entries(a.params ?? {})) { - params[key] = typeof value === "string" ? value : String(value ?? ""); - } - return { type: a.type, params }; - }); -} - -export function AutomationRuleBuilder({ - project, - open, - onOpenChange, - rule = null, - onSaved, -}: RuleBuilderProps) { - const [name, setName] = useState(rule?.name ?? ""); - const [active, setActive] = useState(rule?.active ?? true); - const [triggerType, setTriggerType] = useState( - rule?.trigger.type ?? "task_status_changed" - ); - const [conditions, setConditions] = useState( - toDraftConditions(rule?.conditions ?? []) - ); - const [actions, setActions] = useState( - toDraftActions(rule?.actions ?? []) - ); - - const createMutation = useApiMutation>( - "post", - `/projects/${project.id}/automations` - ); - const updateMutation = useApiMutation>( - "patch", - `/projects/${project.id}/automations/${rule?.id}` - ); - - const isEditing = Boolean(rule?.id); - const isPending = createMutation.isPending || updateMutation.isPending; - - const updateCondition = (index: number, patch: Partial) => { - setConditions((prev) => - prev.map((c, i) => (i === index ? { ...c, ...patch } : c)) - ); - }; - - const updateAction = (index: number, patch: Partial) => { - setActions((prev) => - prev.map((a, i) => (i === index ? { ...a, ...patch } : a)) - ); - }; - - const handleSubmit = () => { - if (!name.trim()) { - toast.error("Rule name is required"); - return; - } - if (actions.length === 0) { - toast.error("Add at least one action"); - return; - } - - // Drop condition rows whose value is still empty. - const validConditions = conditions.filter( - (c) => typeof c.value === "string" && c.value.trim() !== "" - ); - - const payload: Record = { - name: name.trim(), - active, - trigger: { type: triggerType }, - conditions: validConditions.map((c) => ({ field: c.field, op: c.op, value: c.value })), - actions: actions.map((a) => ({ type: a.type, params: a.params })), - }; - - const onSuccess = () => { - toast.success(isEditing ? "Rule updated" : "Rule created"); - onSaved?.(); - onOpenChange(false); - }; - const onError = (err: Error) => toast.error(err.message); - - if (isEditing) { - updateMutation.mutate(payload, { onSuccess, onError }); - } else { - createMutation.mutate(payload, { onSuccess, onError }); - } - }; - - return ( - - - - {isEditing ? "Edit automation rule" : "Create automation rule"} - - When something happens to a task, automatically run actions. - - - -
-
-
- - setName(e.target.value)} - placeholder="e.g. Ship completed tasks" - /> -
-
- - -
-
- -
- - -
- -
-
- - -
- {conditions.length === 0 ? ( -

- No conditions — the rule fires on every matching event. -

- ) : ( - conditions.map((condition, index) => ( -
- - - {condition.field === "status" ? ( - - ) : condition.field === "priority" ? ( - - ) : ( - updateCondition(index, { value: e.target.value })} - placeholder="Label name" - className="min-w-0 flex-1" - /> - )} - -
- )) - )} -
- -
-
- - -
- {actions.length === 0 ? ( -

No actions — add at least one.

- ) : ( - actions.map((action, index) => ( -
- - {action.type === "set_status" ? ( - - ) : action.type === "set_priority" ? ( - - ) : action.type === "add_label" ? ( - - updateAction(index, { params: { ...action.params, label: e.target.value } }) - } - placeholder="Label name" - className="min-w-0 flex-1" - /> - ) : ( - - updateAction(index, { params: { ...action.params, message: e.target.value } }) - } - placeholder="Notification message" - className="min-w-0 flex-1" - /> - )} - -
- )) - )} -
-
- - - - - -
-
- ); -} diff --git a/apps/web/src/components/gantt/gantt-chart.tsx b/apps/web/src/components/gantt/gantt-chart.tsx deleted file mode 100644 index 9c5ae75..0000000 --- a/apps/web/src/components/gantt/gantt-chart.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { useMemo, useState, type ReactNode } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; -import { toast } from "sonner"; -import { addDays, differenceInCalendarDays, endOfDay, format, startOfDay } from "date-fns"; -import { api } from "@/lib/api"; -import { getStatusColor } from "@/lib/status-colors"; -import type { StatusDefinition } from "@/lib/types"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { EmptyState } from "@/components/state"; -import { GanttTimelineHeader } from "./gantt-timeline-header"; -import { GanttTaskBar } from "./gantt-task-bar"; -import { GanttMilestone } from "./gantt-milestone"; -import { GanttDependencyArrow, type TaskPosition } from "./gantt-dependency-arrow"; -import { - getPixelsPerDay, - MILESTONE_BAND_HEIGHT, - positionForDate, - ROW_HEIGHT, - TASK_LIST_WIDTH, - TIMELINE_HEADER_HEIGHT, - toDayStart, - type TimelineMilestone, - type TimelineTask, - type ZoomLevel, -} from "./gantt-utils"; - -interface GanttChartProps { - domainId: string; - projectId: string; - tasks: TimelineTask[]; - milestones: TimelineMilestone[]; - /** Fallback lookup when the API's joined status is null. */ - statuses?: StatusDefinition[]; -} - -function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : "Something went wrong"; -} - -const GRID_LINE_COLOR = "rgba(148,163,184,0.15)"; - -export function GanttChart({ projectId, tasks, milestones, statuses }: GanttChartProps) { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [zoom, setZoom] = useState("week"); - const pixelsPerDay = getPixelsPerDay(zoom); - - const resolveStatus = (task: TimelineTask): StatusDefinition | null => - task.status ?? statuses?.find((s) => s.id === task.statusId) ?? null; - - const dueMutation = useMutation({ - mutationFn: ({ taskId, dueDate }: { taskId: string; dueDate: string }) => - api.patch(`/tasks/${taskId}`, { dueDate }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["timeline"] }); - queryClient.invalidateQueries({ queryKey: ["project", projectId] }); - queryClient.invalidateQueries({ queryKey: ["tasks"] }); - }, - onError: (err) => toast.error(errorMessage(err)), - }); - - const { viewStart, viewEnd, totalDays, totalWidth, todayX, taskPositions, rowsHeight } = useMemo(() => { - const today = startOfDay(new Date()); - const all: Date[] = [today]; - for (const t of tasks) { - all.push(toDayStart(t.startDate)); - if (t.dueDate) all.push(toDayStart(t.dueDate)); - } - for (const m of milestones) all.push(toDayStart(m.targetDate)); - - const minTime = Math.min(...all.map((d) => d.getTime())); - const maxTime = Math.max(...all.map((d) => d.getTime())); - const viewStart = startOfDay(addDays(new Date(minTime), -7)); - const viewEnd = startOfDay(addDays(new Date(maxTime), 7)); - const totalDays = Math.max(differenceInCalendarDays(viewEnd, viewStart) + 1, 7); - const totalWidth = totalDays * pixelsPerDay; - - const positions = new Map(); - tasks.forEach((task, i) => { - const start = toDayStart(task.startDate); - const end = task.dueDate ? toDayStart(task.dueDate) : start; - positions.set(task.id, { - startX: positionForDate(start, viewStart, pixelsPerDay), - endX: positionForDate(end, viewStart, pixelsPerDay), - y: i * ROW_HEIGHT + ROW_HEIGHT / 2, - }); - }); - - return { - viewStart, - viewEnd, - totalDays, - totalWidth, - todayX: positionForDate(today, viewStart, pixelsPerDay), - taskPositions: positions, - rowsHeight: tasks.length * ROW_HEIGHT + MILESTONE_BAND_HEIGHT, - }; - }, [tasks, milestones, pixelsPerDay]); - - const gridBackground = `repeating-linear-gradient(to right, ${GRID_LINE_COLOR} 0, ${GRID_LINE_COLOR} 1px, transparent 1px, transparent ${pixelsPerDay}px)`; - - const rows: ReactNode[] = tasks.map((task, i) => { - const pos = taskPositions.get(task.id); - if (!pos) return null; - return ( -
- dueMutation.mutate({ taskId, dueDate })} - /> -
- ); - }); - - const taskById = new Map(tasks.map((t) => [t.id, t])); - const arrows: ReactNode[] = []; - for (const task of tasks) { - for (const depId of task.dependencies) { - const dep = taskById.get(depId); - if (dep) { - arrows.push( - - ); - } - } - } - - return ( -
-
- { - if (v) setZoom(v as ZoomLevel); - }} - > - Day - Week - Month - - - {format(viewStart, "MMM d")} – {format(addDays(viewStart, totalDays - 1), "MMM d, yyyy")} - -
- - {tasks.length === 0 && milestones.length === 0 ? ( - - ) : ( -
-
- {/* Fixed task list */} -
-
- Tasks · {tasks.length} -
- {tasks.map((task) => ( -
- - -
- ))} -
- Milestones · {milestones.length} -
-
- - {/* Scrollable timeline */} -
-
- -
-
-
- {rows} -
- {milestones.map((m) => ( - - ))} -
- - - - - - - - {arrows} - -
-
-
-
- )} -
- ); -} \ No newline at end of file diff --git a/apps/web/src/components/gantt/gantt-dependency-arrow.tsx b/apps/web/src/components/gantt/gantt-dependency-arrow.tsx deleted file mode 100644 index a7a2bba..0000000 --- a/apps/web/src/components/gantt/gantt-dependency-arrow.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { TimelineTask } from "./gantt-utils"; - -export interface TaskPosition { - /** Left edge of the task's bar in timeline pixels. */ - startX: number; - /** Right edge of the task's bar in timeline pixels. */ - endX: number; - /** Vertical center of the task's row in pixels. */ - y: number; -} - -interface GanttDependencyArrowProps { - /** The task being depended on (arrow originates at the end of its bar). */ - fromTask: TimelineTask; - /** The blocked task (arrowhead lands at the start of its bar). */ - toTask: TimelineTask; - taskPositions: ReadonlyMap; -} - -const BEND = 12; - -/** - * SVG elbow arrow from the end of the blocking task's bar to the start of the - * blocked task's bar. Rendered inside the chart's overlay — the marker - * is defined there under the id `gantt-arrow`. - */ -export function GanttDependencyArrow({ fromTask, toTask, taskPositions }: GanttDependencyArrowProps) { - const from = taskPositions.get(fromTask.id); - const to = taskPositions.get(toTask.id); - if (!from || !to) return null; - - const x1 = from.endX; - const y1 = from.y; - // If the target bar starts before the source ends, drop the arrowhead just - // past the source end so the elbow path never doubles back on itself. - const x2 = Math.max(to.startX, from.endX + BEND); - const y2 = to.y; - const d = `M ${x1} ${y1} H ${x1 + BEND} L ${x2 - BEND} ${y2} H ${x2}`; - - return ( - - ); -} \ No newline at end of file diff --git a/apps/web/src/components/gantt/gantt-milestone.tsx b/apps/web/src/components/gantt/gantt-milestone.tsx deleted file mode 100644 index 1ef81bf..0000000 --- a/apps/web/src/components/gantt/gantt-milestone.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { format, parseISO } from "date-fns"; -import type { TimelineMilestone } from "./gantt-utils"; - -interface GanttMilestoneProps { - milestone: TimelineMilestone; - /** Timeline pixel x where the diamond's center should sit. */ - x: number; -} - -/** Diamond marker for a milestone, vertically centered with its date label. */ -export function GanttMilestone({ milestone, x }: GanttMilestoneProps) { - return ( -
-
- - {format(parseISO(milestone.targetDate), "MMM d")} - -
- ); -} \ No newline at end of file diff --git a/apps/web/src/components/gantt/gantt-task-bar.tsx b/apps/web/src/components/gantt/gantt-task-bar.tsx deleted file mode 100644 index 9307d70..0000000 --- a/apps/web/src/components/gantt/gantt-task-bar.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useRef, useState } from "react"; -import { addDays, differenceInCalendarDays, format, formatISO, parseISO } from "date-fns"; -import { cn } from "@/lib/utils"; -import { positionForDate, toDayStart, type TimelineTask } from "./gantt-utils"; - -interface GanttTaskBarProps { - task: TimelineTask; - /** Left edge of the bar in timeline pixels. */ - startX: number; - /** Bar width in timeline pixels (right edge = startX + width). */ - width: number; - color: string; - pixelsPerDay: number; - viewStart: Date; - onCommit: (taskId: string, dueDate: string) => void; -} - -interface DragState { - startClientX: number; - origDue: Date; - lastDue: Date; - moved: boolean; -} - -/** - * A task bar on the timeline. Tasks only carry an end date (dueDate), so both - * dragging the body and pulling the right resize handle move the due date; the - * bar stays anchored at its start (createdAt) date. A task without a due date - * renders as a small stub that becomes a 1-day bar when dragged. - */ -export function GanttTaskBar({ task, startX, width, color, pixelsPerDay, viewStart, onCommit }: GanttTaskBarProps) { - const [dragDue, setDragDue] = useState(null); - const dragRef = useRef(null); - - const startDate = toDayStart(task.startDate); - const origDue = task.dueDate ? toDayStart(task.dueDate) : startDate; - const endDate = dragDue ?? origDue; - const barWidth = Math.max(positionForDate(endDate, viewStart, pixelsPerDay) - startX, 6); - const isDone = task.status?.category === "done"; - const isCancelled = task.status?.category === "cancelled"; - const title = - task.dueDate && task.dueDate !== task.startDate - ? `${task.title} — due ${format(parseISO(task.dueDate), "MMM d, yyyy")}` - : task.title; - - const beginDrag = (e: React.PointerEvent) => { - if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - const initial = task.dueDate ? toDayStart(task.dueDate) : startDate; - dragRef.current = { startClientX: e.clientX, origDue: initial, lastDue: initial, moved: false }; - setDragDue(initial); - - const onMove = (ev: PointerEvent) => { - const state = dragRef.current; - if (!state) return; - const dx = ev.clientX - state.startClientX; - const days = Math.round(dx / pixelsPerDay); - let next = addDays(state.origDue, days); - if (next < startDate) next = startDate; - state.lastDue = next; - if (differenceInCalendarDays(next, state.origDue) !== 0) state.moved = true; - setDragDue(next); - }; - const onUp = () => { - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); - const state = dragRef.current; - dragRef.current = null; - setDragDue(null); - if (state?.moved) onCommit(task.id, formatISO(state.lastDue)); - }; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); - }; - - return ( -
- {barWidth >= 30 ? {task.title} : null} -
-
- ); -} \ No newline at end of file diff --git a/apps/web/src/components/gantt/gantt-timeline-header.tsx b/apps/web/src/components/gantt/gantt-timeline-header.tsx deleted file mode 100644 index 597fa88..0000000 --- a/apps/web/src/components/gantt/gantt-timeline-header.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { format, getDaysInMonth, startOfMonth } from "date-fns"; -import { cn } from "@/lib/utils"; -import { - getDateRange, - positionForDate, - TIMELINE_HEADER_HEIGHT, - type ZoomLevel, -} from "./gantt-utils"; - -interface GanttTimelineHeaderProps { - viewStart: Date; - viewEnd: Date; - zoom: ZoomLevel; - pixelsPerDay: number; -} - -function cellWidth(cell: Date, zoom: ZoomLevel, pixelsPerDay: number): number { - switch (zoom) { - case "day": - return pixelsPerDay; - case "week": - return 7 * pixelsPerDay; - case "month": - return getDaysInMonth(cell) * pixelsPerDay; - } -} - -/** - * Two-row date header: a group label row (months for day/week zoom, years for - * month zoom) above the per-column cells. Positioned absolutely inside a - * container that spans the full timeline width so it can be made sticky by the - * parent chart. - */ -export function GanttTimelineHeader({ viewStart, viewEnd, zoom, pixelsPerDay }: GanttTimelineHeaderProps) { - const cells = getDateRange(viewStart, viewEnd, zoom); - - // Group consecutive cells into spans for the top row. - const groups: { key: string; label: string; start: Date; end: Date }[] = []; - for (const cell of cells) { - const key = zoom === "month" ? String(cell.getFullYear()) : format(startOfMonth(cell), "yyyy-MM"); - const label = zoom === "month" ? String(cell.getFullYear()) : format(startOfMonth(cell), "MMMM yyyy"); - const last = groups[groups.length - 1]; - if (last && last.key === key) { - last.end = cell; - } else { - groups.push({ key, label, start: cell, end: cell }); - } - } - - const renderGroup = (group: { key: string; label: string; start: Date; end: Date }) => { - const left = positionForDate(group.start, viewStart, pixelsPerDay); - const width = - positionForDate(group.end, viewStart, pixelsPerDay) + - cellWidth(group.end, zoom, pixelsPerDay) - - left; - return ( -
- {group.label} -
- ); - }; - - const renderCell = (cell: Date) => { - const left = positionForDate(cell, viewStart, pixelsPerDay); - const width = cellWidth(cell, zoom, pixelsPerDay); - const isWeekend = zoom === "day" && (cell.getDay() === 0 || cell.getDay() === 6); - const label = - zoom === "day" - ? format(cell, "EEE d") - : zoom === "week" - ? format(cell, "MMM d") - : format(cell, "MMMM"); - return ( -
- {label} -
- ); - }; - - return ( -
-
- {groups.map(renderGroup)} -
-
- {cells.map(renderCell)} -
-
- ); -} \ No newline at end of file diff --git a/apps/web/src/components/gantt/gantt-utils.ts b/apps/web/src/components/gantt/gantt-utils.ts deleted file mode 100644 index d21894a..0000000 --- a/apps/web/src/components/gantt/gantt-utils.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - addDays, - differenceInCalendarDays, - eachDayOfInterval, - eachMonthOfInterval, - eachWeekOfInterval, - endOfDay, - endOfMonth, - parseISO, - startOfDay, - startOfMonth, - startOfWeek, -} from "date-fns"; -import type { StatusDefinition } from "@/lib/types"; - -export type ZoomLevel = "day" | "week" | "month"; - -export interface TimelineTask { - id: string; - title: string; - startDate: string; - dueDate: string | null; - statusId: string | null; - status: StatusDefinition | null; - sectionId: string | null; - dependencies: string[]; -} - -export interface TimelineMilestone { - id: string; - name: string; - targetDate: string; -} - -export interface TimelineData { - tasks: TimelineTask[]; - milestones: TimelineMilestone[]; -} - -export const ROW_HEIGHT = 40; -export const MILESTONE_BAND_HEIGHT = 48; -export const TIMELINE_HEADER_HEIGHT = 56; -export const TASK_LIST_WIDTH = 224; - -const PIXELS_PER_DAY: Record = { - day: 36, - week: 12, - month: 5, -}; - -export function getPixelsPerDay(zoom: ZoomLevel): number { - return PIXELS_PER_DAY[zoom]; -} - -/** Normalize a date (or ISO string) to local midnight. */ -export function toDayStart(date: Date | string): Date { - return startOfDay(typeof date === "string" ? parseISO(date) : date); -} - -/** Horizontal pixel offset of a date from the view start (local calendar days). */ -export function positionForDate(date: Date | string, viewStart: Date, pixelsPerDay: number): number { - return differenceInCalendarDays(toDayStart(date), startOfDay(viewStart)) * pixelsPerDay; -} - -/** Date (local midnight) at a given horizontal pixel offset from the view start. */ -export function dateForPosition(x: number, viewStart: Date, pixelsPerDay: number): Date { - return addDays(startOfDay(viewStart), Math.round(x / pixelsPerDay)); -} - -/** Column start dates for the timeline header at the given zoom. */ -export function getDateRange(start: Date, end: Date, zoom: ZoomLevel): Date[] { - const s = startOfDay(start); - const e = endOfDay(end); - switch (zoom) { - case "day": - return eachDayOfInterval({ start: s, end: e }); - case "week": - return eachWeekOfInterval({ start: startOfWeek(s, { weekStartsOn: 1 }), end: e }, { weekStartsOn: 1 }); - case "month": - return eachMonthOfInterval({ start: startOfMonth(s), end: endOfMonth(e) }); - } -} \ No newline at end of file diff --git a/apps/web/src/components/notification-center.tsx b/apps/web/src/components/notification-center.tsx deleted file mode 100644 index 0052e02..0000000 --- a/apps/web/src/components/notification-center.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "@tanstack/react-router"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - AlarmClock, - ArrowRightLeft, - AtSign, - Bell, - Bot, - Check, - Inbox, - RefreshCw, - UserPlus, - type LucideIcon, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useApiQuery, useApiMutation, api } from "@/lib/api"; -import { useApiDomain } from "@/lib/stores/use-active-domain-store"; -import { useRealtime } from "@/hooks/use-realtime"; -import { formatDistanceToNow } from "date-fns"; -import type { Notification, NotificationCount, NotificationsResponse } from "@/lib/types"; - -const NOTIFICATION_META: Record = { - mention: { icon: AtSign, color: "text-blue-500" }, - status_change: { icon: ArrowRightLeft, color: "text-violet-500" }, - due_soon: { icon: AlarmClock, color: "text-amber-500" }, - automation: { icon: Bot, color: "text-emerald-500" }, - assignment: { icon: UserPlus, color: "text-cyan-500" }, -}; - -/** Navigate to the entity a notification points at. Returns true when a route - * was matched (and the sheet should close). */ -function navigateToEntity(navigate: ReturnType, n: Notification): boolean { - if (!n.entityId || !n.entityType) return false; - switch (n.entityType) { - case "task": - navigate({ to: "/tasks/$id", params: { id: n.entityId } }); - return true; - case "note": - navigate({ to: "/notes/$id", params: { id: n.entityId } }); - return true; - case "project": - navigate({ to: "/projects/$id", params: { id: n.entityId } }); - return true; - case "habit": - navigate({ to: "/habits/$id", params: { id: n.entityId } }); - return true; - default: - return false; - } -} - -export function NotificationCenter() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const domainId = useApiDomain(); - const [open, setOpen] = useState(false); - - // Own SSE connection so the badge stays live regardless of which page is - // mounted; notification events invalidate the count + list queries. - useRealtime({ enabled: true }); - - const countQuery = useApiQuery( - ["notifications-count", domainId], - "/notifications/count" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}` : ""), - { enabled: !!domainId, refetchInterval: 30_000 } - ); - const unreadCount = countQuery.data?.count ?? 0; - - const listQuery = useApiQuery( - ["notifications", domainId], - "/notifications" + (domainId ? `?workspace_id=${encodeURIComponent(domainId)}&limit=50` : ""), - { enabled: !!domainId && open } - ); - const notifications = listQuery.data?.items ?? []; - const loading = listQuery.isLoading || listQuery.isFetching; - - const invalidateNotifications = () => { - queryClient.invalidateQueries({ queryKey: ["notifications-count"] }); - queryClient.invalidateQueries({ queryKey: ["notifications"] }); - }; - - const markRead = useMutation({ - mutationFn: (id: string) => api.patch(`/notifications/${id}`), - onMutate: (id) => { - // Optimistically decrement the badge so the UI feels instant. - queryClient.setQueryData(["notifications-count", domainId], (old) => - old && old.count > 0 ? { count: old.count - 1 } : old - ); - queryClient.setQueryData(["notifications", domainId], (old) => - old - ? { - ...old, - items: old.items.map((n) => (n.id === id && !n.readAt ? { ...n, readAt: new Date().toISOString() } : n)), - unreadCount: Math.max(0, old.unreadCount - 1), - } - : old - ); - return id; - }, - onSuccess: invalidateNotifications, - }); - - const markAllRead = useApiMutation<{ success: boolean; updated: number }, { workspace_id?: string }>( - "post", - "/notifications/read-all", - { - onMutate: () => { - queryClient.setQueryData(["notifications-count", domainId], (old) => - old ? { count: 0 } : old - ); - queryClient.setQueryData(["notifications", domainId], (old) => - old - ? { - ...old, - items: old.items.map((n) => (n.readAt ? n : { ...n, readAt: new Date().toISOString() })), - unreadCount: 0, - } - : old - ); - }, - onSuccess: invalidateNotifications, - } - ); - - const handleNotificationClick = (n: Notification) => { - if (!n.readAt) markRead.mutate(n.id); - if (navigateToEntity(navigate, n)) { - setOpen(false); - } - }; - - const badgeLabel = unreadCount > 99 ? "99+" : String(unreadCount); - - return ( - - - - - - - - - - {unreadCount === 0 ? "No notifications" : `${unreadCount} unread notification${unreadCount === 1 ? "" : "s"}`} - - - - - - - Notifications -
- - -
-
- - - {loading && notifications.length === 0 ? ( -
Loading notifications…
- ) : notifications.length === 0 ? ( -
- - No notifications yet -
- ) : ( -
    - {notifications.map((n) => { - const meta = NOTIFICATION_META[n.type] ?? { icon: Bell, color: "text-muted-foreground" }; - const Icon = meta.icon; - const unread = !n.readAt; - return ( -
  • - -
  • - ); - })} -
- )} -
-
-
- ); -} diff --git a/apps/web/src/components/quick-add-bar.tsx b/apps/web/src/components/quick-add-bar.tsx deleted file mode 100644 index 1608ef4..0000000 --- a/apps/web/src/components/quick-add-bar.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { Plus, Calendar, Clock, Flag, FolderKanban, Tag as TagIcon, Repeat, CornerDownLeft } from "lucide-react"; -import { api, useApiQuery } from "@/lib/api"; -import { useApiDomain } from "@/lib/stores/use-active-domain-store"; -import { parseQuickAdd, type ParsedTask, type QuickAddContext } from "@/lib/nlp-parser"; -import { PRIORITY } from "@/lib/status-colors"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import type { Project, Tag, StatusDefinition, PaginatedResponse } from "@/lib/types"; - -function PreviewChip({ - icon, - label, - className, -}: { - icon: React.ReactNode; - label: string; - className?: string; -}) { - return ( - - {icon} - {label} - - ); -} - -export function QuickAddBar() { - const queryClient = useQueryClient(); - const activeDomainId = useApiDomain(); - const inputRef = useRef(null); - const [text, setText] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [focused, setFocused] = useState(false); - - // Projects and tags in the active domain, used to resolve #proj / @tag tokens. - const { data: projectsData } = useApiQuery>( - ["projects", activeDomainId], - activeDomainId ? `/projects?limit=200&domain=${activeDomainId}` : "", - { enabled: !!activeDomainId } - ); - const { data: tagsData } = useApiQuery>( - ["tags", activeDomainId], - "/tags?perPage=200", - { enabled: !!activeDomainId } - ); - - const projects = projectsData?.items ?? []; - const tags = tagsData?.items ?? []; - - // Context for the parser: the set of known project/tag names. - const context: QuickAddContext = useMemo( - () => ({ projectNames: projects.map((p) => p.name), tagNames: tags.map((t) => t.name) }), - [projects, tags] - ); - - const parsed = useMemo(() => parseQuickAdd(text, context), [text, context]); - - // Project statuses (to find the "todo" status when a project is selected). - const resolvedProject = parsed.project - ? projects.find((p) => p.name.toLowerCase() === parsed.project!.toLowerCase()) - : undefined; - const { data: statusesData } = useApiQuery<{ items: StatusDefinition[] }>( - ["project-statuses", resolvedProject?.id ?? "none"], - resolvedProject ? `/projects/${resolvedProject.id}/statuses` : "", - { enabled: !!resolvedProject } - ); - const todoStatus = statusesData?.items?.find((s) => s.category === "todo"); - - // Keyboard shortcut: `n` (no modifiers, outside editable fields) focuses the - // bar. Mirrors the app's single-key shortcut pattern (?, /, c). - useEffect(() => { - const handler = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if ( - e.metaKey || e.ctrlKey || e.altKey || - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable || - target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]') - ) { - return; - } - if (e.key.toLowerCase() === "n") { - e.preventDefault(); - inputRef.current?.focus(); - } - }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, []); - - const canSubmit = parsed.title.trim().length > 0 && !isSubmitting; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!canSubmit) return; - setIsSubmitting(true); - try { - // Resolve matched names back to ids for the API payload. - const tagIds = parsed.tags - ?.map((name) => tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.id) - .filter((id): id is string => !!id); - - await api.post("/tasks", { - title: parsed.title, - ...(parsed.priority ? { priority: parsed.priority } : {}), - ...(resolvedProject ? { projectId: resolvedProject.id } : {}), - ...(parsed.dueDate ? { dueDate: parsed.dueDate.toISOString() } : {}), - ...(parsed.recurrence ? { recurrenceRule: parsed.recurrence } : {}), - ...(todoStatus ? { statusId: todoStatus.id } : {}), - ...(tagIds && tagIds.length > 0 ? { tagIds } : {}), - ...(activeDomainId ? { domain: activeDomainId } : {}), - }); - - queryClient.invalidateQueries({ queryKey: ["tasks"] }); - queryClient.invalidateQueries({ queryKey: ["projects"] }); - setText(""); - toast.success("Created!"); - } catch (err) { - toast.error((err as Error).message || "Failed to create task"); - } finally { - setIsSubmitting(false); - } - }; - - // Preview chips describing what the parser detected. - const chips: React.ReactNode[] = []; - if (parsed.dueDate) { - chips.push( - } - label={parsed.dueDate.toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - }) + (parsed.dueDate.getHours() || parsed.dueDate.getMinutes() - ? ` ${parsed.dueDate.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}` - : "")} - /> - ); - } - if (parsed.priority) { - chips.push( - } - label={PRIORITY[parsed.priority]?.label ?? parsed.priority} - className="text-orange-500" - /> - ); - } - if (resolvedProject) { - chips.push( - } - label={resolvedProject.name} - className="text-violet-500" - /> - ); - } - if (parsed.tags?.length) { - for (const tag of parsed.tags) { - chips.push( - } - label={tag} - className="text-sky-500" - /> - ); - } - } - if (parsed.recurrence) { - chips.push( - } label="Recurring" /> - ); - } - - return ( -
-
setFocused(true)} - onBlur={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false); - }} - > -
- - setText(e.target.value)} - placeholder="Quick add: 'buy milk tomorrow !high #work'" - className="h-9 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" - aria-label="Quick add task" - /> - -
- {focused && (chips.length > 0 || text.trim().length > 0) && ( -
- {chips.length > 0 ? ( - chips - ) : ( - - !high priority ·{" "} - #project ·{" "} - @tag ·{" "} - tomorrow - - )} -
- )} -
-
- ); -} diff --git a/apps/web/src/lib/nlp-parser.test.ts b/apps/web/src/lib/nlp-parser.test.ts deleted file mode 100644 index 8f59298..0000000 --- a/apps/web/src/lib/nlp-parser.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { parseQuickAdd } from "./nlp-parser"; - -// Deterministic "now" so date math is stable across runs. Tests that rely on -// calendar dates are anchored relative to this fixed reference point. -const NOW = new Date("2026-08-19T12:00:00"); // a Wednesday - -function parse(input: string, context?: Parameters[1]) { - return parseQuickAdd(input, context); -} - -// Helper: same calendar day check regardless of time component. -function sameDay(a: Date, b: Date): boolean { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); -} - -// Patch Date.now for the tests that compute relative dates. -describe("nlp-parser", () => { - const realNow = Date.now; - beforeEach(() => { - Date.now = () => NOW.getTime(); - }); - afterEach(() => { - Date.now = realNow; - }); - - describe("title extraction", () => { - it("keeps plain text as the title", () => { - const r = parse("buy milk"); - expect(r.title).toBe("buy milk"); - }); - - it("removes recognized tokens from the title", () => { - const r = parse("buy milk tomorrow !high"); - expect(r.title).toBe("buy milk"); - }); - - it("trims surrounding whitespace from the title", () => { - const r = parse(" buy milk tomorrow "); - expect(r.title).toBe("buy milk"); - }); - - it("keeps multiple words in original order", () => { - const r = parse("fix login bug in the auth flow"); - expect(r.title).toBe("fix login bug in the auth flow"); - }); - }); - - describe("priority", () => { - it("parses !urgent", () => { - expect(parse("ship !urgent").priority).toBe("urgent"); - }); - it("parses !high", () => { - expect(parse("ship !high").priority).toBe("high"); - }); - it("parses !medium", () => { - expect(parse("ship !medium").priority).toBe("medium"); - }); - it("parses !low", () => { - expect(parse("ship !low").priority).toBe("low"); - }); - it("parses !! as urgent", () => { - expect(parse("ship !!").priority).toBe("urgent"); - }); - it("is case-insensitive", () => { - expect(parse("ship !HIGH").priority).toBe("high"); - }); - }); - - describe("dates", () => { - it("parses tomorrow as next day", () => { - const r = parse("buy milk tomorrow"); - expect(r.dueDate).toBeDefined(); - expect(sameDay(r.dueDate!, new Date(2026, 7, 20))).toBe(true); - }); - - it("parses in 3 days", () => { - const r = parse("fix bug in 3 days"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 22))).toBe(true); - }); - - it("parses in 2 weeks", () => { - const r = parse("plan in 2 weeks"); - expect(sameDay(r.dueDate!, new Date(2026, 8, 2))).toBe(true); - }); - - it("parses next friday", () => { - // NOW is Wed 2026-08-19; next friday is 2026-08-21. - const r = parse("review PR next friday"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 21))).toBe(true); - }); - - it("parses a bare weekday as the next occurrence", () => { - // NOW is Wed 2026-08-19; next monday is 2026-08-24. - const r = parse("standup monday"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 24))).toBe(true); - }); - - it("parses ISO date 2025-01-15", () => { - const r = parse("deadline 2025-01-15"); - expect(sameDay(r.dueDate!, new Date(2025, 0, 15))).toBe(true); - }); - - it("parses month name + day (dec 25)", () => { - const r = parse("gift dec 25"); - expect(sameDay(r.dueDate!, new Date(2026, 11, 25))).toBe(true); - }); - - it("parses end of month", () => { - const r = parse("report end of month"); - // Aug 2026 has 31 days. - expect(sameDay(r.dueDate!, new Date(2026, 7, 31))).toBe(true); - }); - - it("parses next week", () => { - const r = parse("event next week"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 26))).toBe(true); - }); - }); - - describe("times", () => { - it("parses at 2pm on a date", () => { - const r = parse("call tomorrow at 2pm"); - expect(r.dueDate!.getHours()).toBe(14); - expect(r.dueDate!.getMinutes()).toBe(0); - }); - - it("parses at 9am on a date", () => { - const r = parse("standup tomorrow at 9am"); - expect(r.dueDate!.getHours()).toBe(9); - }); - - it("parses 24h time at 14:30", () => { - const r = parse("meeting tomorrow at 14:30"); - expect(r.dueDate!.getHours()).toBe(14); - expect(r.dueDate!.getMinutes()).toBe(30); - }); - - it("removes the time phrase from the title", () => { - const r = parse("call tomorrow at 2pm"); - expect(r.title).toBe("call"); - }); - }); - - describe("projects and tags", () => { - it("resolves #project to a known project name", () => { - const r = parse("buy milk #work", { projectNames: ["Work", "Personal"] }); - expect(r.project).toBe("Work"); - }); - - it("resolves @tag to a known tag name", () => { - const r = parse("task @sarah", { tagNames: ["sarah", "billing"] }); - expect(r.tags).toEqual(["sarah"]); - }); - - it("keeps unknown #project in the title", () => { - const r = parse("fix #hashtag bug", { projectNames: ["Work"] }); - expect(r.project).toBeUndefined(); - expect(r.title).toContain("#hashtag"); - }); - - it("keeps unknown @tag in the title", () => { - const r = parse("mention @nobody", { tagNames: ["sarah"] }); - expect(r.tags).toBeUndefined(); - expect(r.title).toContain("@nobody"); - }); - - it("resolves multiple tags", () => { - const r = parse("task @a @b", { tagNames: ["a", "b", "c"] }); - expect(r.tags).toEqual(["a", "b"]); - }); - }); - - describe("recurrence", () => { - it("parses daily", () => { - expect(parse("standup daily").recurrence).toBe("FREQ=DAILY"); - }); - it("parses weekly", () => { - expect(parse("review weekly").recurrence).toBe("FREQ=WEEKLY"); - }); - it("parses monthly", () => { - expect(parse("report monthly").recurrence).toBe("FREQ=MONTHLY"); - }); - it("parses every monday", () => { - expect(parse("standup every monday").recurrence).toBe("FREQ=WEEKLY;BYDAY=MO"); - }); - it("parses every 2 weeks", () => { - expect(parse("review every 2 weeks").recurrence).toBe("FREQ=WEEKLY;INTERVAL=2"); - }); - it("parses every month on the 15th", () => { - expect(parse("bill every month on the 15th").recurrence).toBe( - "FREQ=MONTHLY;BYMONTHDAY=15" - ); - }); - it("removes recurrence words from the title", () => { - const r = parse("standup every monday"); - expect(r.title).toBe("standup"); - }); - }); - - describe("combined & edge cases", () => { - it("parses a full example", () => { - const r = parse("buy milk tomorrow !high #work @sarah every monday", { - projectNames: ["work"], - tagNames: ["sarah"], - }); - expect(r.title).toBe("buy milk"); - expect(r.priority).toBe("high"); - expect(r.project).toBe("work"); - expect(r.tags).toEqual(["sarah"]); - expect(r.recurrence).toBe("FREQ=WEEKLY;BYDAY=MO"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 20))).toBe(true); - }); - - it("parses review PR !urgent next friday", () => { - const r = parse("review PR !urgent next friday"); - expect(r.title).toBe("review PR"); - expect(r.priority).toBe("urgent"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 21))).toBe(true); - }); - - it("parses team standup daily at 9am #engineering", () => { - const r = parse("team standup daily at 9am #engineering", { - projectNames: ["engineering"], - }); - expect(r.title).toBe("team standup"); - expect(r.recurrence).toBe("FREQ=DAILY"); - expect(r.dueDate!.getHours()).toBe(9); - expect(r.project).toBe("engineering"); - }); - - it("parses fix login bug in 3 days !high #backend", () => { - const r = parse("fix login bug in 3 days !high #backend", { - projectNames: ["backend"], - }); - expect(r.title).toBe("fix login bug"); - expect(r.priority).toBe("high"); - expect(r.project).toBe("backend"); - expect(sameDay(r.dueDate!, new Date(2026, 7, 22))).toBe(true); - }); - - it("keeps the raw input", () => { - const input = "buy milk tomorrow !high"; - expect(parse(input).raw).toBe(input); - }); - - it("returns empty title for only-metadata input", () => { - const r = parse("!high tomorrow", { projectNames: [] }); - expect(r.title).toBe(""); - expect(r.priority).toBe("high"); - }); - }); -}); diff --git a/apps/web/src/lib/nlp-parser.ts b/apps/web/src/lib/nlp-parser.ts deleted file mode 100644 index c77d0e3..0000000 --- a/apps/web/src/lib/nlp-parser.ts +++ /dev/null @@ -1,458 +0,0 @@ -/** - * Natural-language quick-add parser. - * - * A pure, dependency-light tokenizer that turns free-text task input into - * structured task data: - * - * "buy milk tomorrow !high #work @sarah every monday" - * → title: "buy milk", dueDate: tomorrow, priority: "high", - * project: "work" (resolved against context), tags: ["sarah"], - * recurrence: "FREQ=WEEKLY;BYDAY=MO" - * - * No network calls, no external NLP library — just regex tokenization over a - * normalized token stream, evaluated in the browser's local timezone. - */ - -export type QuickAddPriority = "low" | "medium" | "high" | "urgent"; - -export interface ParsedTask { - /** The remaining free text with all recognized tokens removed. */ - title: string; - /** Resolved absolute due date (local timezone), if one was given. */ - dueDate?: Date; - priority?: QuickAddPriority; - /** - * The matched project NAME (from `#project`), when it matches a known name - * in `context.projectNames`. The caller maps this name to an id before - * sending the create request. - */ - project?: string; - /** Matched tag NAMES (from `@label`), when they match `context.tagNames`. */ - tags?: string[]; - /** An RFC 5545 RRULE string (e.g. "FREQ=WEEKLY;BYDAY=MO"). */ - recurrence?: string; - /** The original, unmodified input string. */ - raw: string; -} - -export interface QuickAddContext { - projectNames?: string[]; - tagNames?: string[]; -} - -// ── Helpers ──────────────────────────────────────────────────────────────────── - -/** Strip leading zeros so "02" reads as "2" (used for ordinal date math). */ -function num(n: string): number { - return parseInt(n.replace(/^0+/, "") || "0", 10); -} - -/** Start-of-day in the local timezone. All date tokens are anchored to this. */ -function startOfDay(d: Date): Date { - const copy = new Date(d); - copy.setHours(0, 0, 0, 0); - return copy; -} - -function addDays(d: Date, days: number): Date { - const copy = new Date(d); - copy.setDate(copy.getDate() + days); - return copy; -} - -function addMonths(d: Date, months: number): Date { - const copy = new Date(d); - copy.setMonth(copy.getMonth() + months); - return copy; -} - -/** Next occurrence of `weekday` (0=Sun..6=Sat). When includeToday, today counts. */ -function nextWeekday(from: Date, weekday: number, includeToday: boolean): Date { - let d = startOfDay(from); - if (!includeToday) d = addDays(d, 1); - while (d.getDay() !== weekday) d = addDays(d, 1); - return d; -} - -// ── Tokenization ────────────────────────────────────────────────────────────── - -type TokenKind = - | "word" - | "priority" - | "project" - | "tag" - | "date" - | "time" - | "recurrence"; - -interface Token { - kind: TokenKind; - value: string; -} - -/** - * Break the input into a token stream, tagging each token with its kind. - * Plain words are kept verbatim (the title is rebuilt from them in order). - */ -function tokenize(input: string): Token[] { - const tokens: Token[] = []; - const re = - /(\*\*)|(!urgent|!high|!medium|!low)|(#[^\s]+)|(@[^\s]+)|(\d{4}-\d{2}-\d{2})|(\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)|([0-2]?\d:\d{2}\s?(?:am|pm)?)|([^\s]+)/gi; - - for (const m of input.matchAll(re)) { - const full = m[0]; - if (!full) continue; - - if (/^!!$/.test(full)) { - tokens.push({ kind: "priority", value: "urgent" }); - } else if (/^!(urgent|high|medium|low)$/i.test(full)) { - tokens.push({ kind: "priority", value: full.slice(1).toLowerCase() }); - } else if (/^#[^\s]+$/.test(full)) { - tokens.push({ kind: "project", value: full.slice(1) }); - } else if (/^@[^\s]+$/.test(full)) { - tokens.push({ kind: "tag", value: full.slice(1) }); - } else if (/^\d{4}-\d{2}-\d{2}$/.test(full) || /^\d{1,2}\/\d{1,2}(?:\/\d{2,4})?$/.test(full)) { - tokens.push({ kind: "date", value: full }); - } else if (/^[0-2]?\d:\d{2}\s?(?:am|pm)?$/i.test(full)) { - tokens.push({ kind: "time", value: full }); - } else { - tokens.push({ kind: "word", value: full }); - } - } - - return tokens; -} - -// ── Recurrence parsing ──────────────────────────────────────────────────────── - -const WEEKDAY_MAP: Record = { - sun: 0, sunday: 0, - mon: 1, monday: 1, - tue: 2, tues: 2, tuesday: 2, - wed: 3, wednesday: 3, - thu: 4, thur: 4, thurs: 4, thursday: 4, - fri: 5, friday: 5, - sat: 6, saturday: 6, -}; - -const MONTH_MAP: Record = { - jan: 0, january: 0, - feb: 1, february: 1, - mar: 2, march: 2, - apr: 3, april: 3, - may: 4, - jun: 5, june: 5, - jul: 6, july: 6, - aug: 7, august: 7, - sep: 8, sept: 8, september: 8, - oct: 9, october: 9, - nov: 10, november: 10, - dec: 11, december: 11, -}; - -/** - * Parse a recurrence phrase into an RRULE (RFC 5545), or null if the window - * does not start with a recurrence. Returns the rule and how many words it - * consumed. - */ -function parseRecurrence(words: string[]): { rrule: string; consumed: number } | null { - const low = words.map((w) => w.toLowerCase()); - - if (low[0] === "daily") return { rrule: "FREQ=DAILY", consumed: 1 }; - if (low[0] === "weekly") return { rrule: "FREQ=WEEKLY", consumed: 1 }; - if (low[0] === "monthly") return { rrule: "FREQ=MONTHLY", consumed: 1 }; - if (low[0] === "yearly") return { rrule: "FREQ=YEARLY", consumed: 1 }; - - if (low[0] === "every") { - let i = 1; - let interval = 1; - if (/^\d+$/.test(low[i] ?? "")) { - interval = num(low[i]); - i += 1; - } - - // Omit INTERVAL when it's the default 1 to keep rules concise. - const intervalPart = interval !== 1 ? `;INTERVAL=${interval}` : ""; - const unit = low[i]; - if (unit === "day" || unit === "days") return { rrule: `FREQ=DAILY${intervalPart}`, consumed: i + 1 }; - if (unit === "week" || unit === "weeks") return { rrule: `FREQ=WEEKLY${intervalPart}`, consumed: i + 1 }; - if (unit === "month" || unit === "months") { - // "every month on the 15th" - if ( - (low[i + 1] === "on" && low[i + 2] === "the" && /^(\d+)(st|nd|rd|th)?$/.test(low[i + 3] ?? "")) - ) { - const day = num(low[i + 3]); - if (day >= 1 && day <= 31) { - return { rrule: `FREQ=MONTHLY${intervalPart};BYMONTHDAY=${day}`, consumed: i + 4 }; - } - } - return { rrule: `FREQ=MONTHLY${intervalPart}`, consumed: i + 1 }; - } - if (unit === "year" || unit === "years") return { rrule: `FREQ=YEARLY${intervalPart}`, consumed: i + 1 }; - - if (WEEKDAY_MAP[unit] !== undefined) { - const byday = unit.slice(0, 2).toUpperCase(); - return { rrule: `FREQ=WEEKLY${intervalPart};BYDAY=${byday}`, consumed: i + 1 }; - } - - return null; - } - - return null; -} - -// ── Date & time parsing ─────────────────────────────────────────────────────── - -/** Parse a single-word date token (ISO date or slash date). */ -function parseSingleWordDate(word: string, now: Date): Date | null { - const low = word.toLowerCase(); - - const iso = low.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); - if (iso) { - const y = num(iso[1]); - const m = num(iso[2]) - 1; - const d = num(iso[3]); - const date = new Date(y, m, d, 0, 0, 0, 0); - if (!isNaN(date.getTime()) && date.getFullYear() === y && date.getMonth() === m && date.getDate() === d) { - return date; - } - return null; - } - - const slash = low.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/); - if (slash) { - const a = num(slash[1]); - const b = num(slash[2]); - if (slash[3]) { - let y = num(slash[3]); - if (y < 100) y += 2000; - const date = new Date(y, a - 1, b, 0, 0, 0, 0); - return isNaN(date.getTime()) ? null : date; - } - // MM/DD — next future occurrence - let date = new Date(now.getFullYear(), a - 1, b, 0, 0, 0, 0); - if (isNaN(date.getTime())) return null; - if (date < startOfDay(now)) date = new Date(now.getFullYear() + 1, a - 1, b, 0, 0, 0, 0); - return date; - } - - return null; -} - -/** Parse a "dec 25" phrase (month name + day). Consumes 2 words. */ -function parseMonthDay(words: string[], now: Date): { date: Date; consumed: number } | null { - const low = words.map((w) => w.toLowerCase()); - if (MONTH_MAP[low[0]] !== undefined && /^(\d{1,2})(st|nd|rd|th)?$/.test(low[1] ?? "")) { - const day = num(low[1]); - if (day < 1 || day > 31) return null; - const month = MONTH_MAP[low[0]]; - let date = new Date(now.getFullYear(), month, day, 0, 0, 0, 0); - if (isNaN(date.getTime())) return null; - if (date < startOfDay(now)) date = new Date(now.getFullYear() + 1, month, day, 0, 0, 0, 0); - return { date, consumed: 2 }; - } - return null; -} - -/** - * Parse a multi-word date phrase ("tomorrow", "next friday", "in 3 days", - * "end of month"). Returns the date plus how many words were consumed. - */ -function parseDatePhrase( - words: string[], - now: Date -): { date: Date; consumed: number } | null { - const low = words.map((w) => w.toLowerCase()); - - if (low[0] === "tomorrow") return { date: addDays(startOfDay(now), 1), consumed: 1 }; - if (low[0] === "today" || low[0] === "tonight") return { date: startOfDay(now), consumed: 1 }; - - if (low[0] === "in" && /^\d+$/.test(low[1] ?? "")) { - const n = num(low[1]); - if (low[2] === "days" || low[2] === "day") return { date: addDays(startOfDay(now), n), consumed: 3 }; - if (low[2] === "weeks" || low[2] === "week") return { date: addDays(startOfDay(now), n * 7), consumed: 3 }; - if (low[2] === "months" || low[2] === "month") return { date: addMonths(startOfDay(now), n), consumed: 3 }; - if (low[2] === "hours" || low[2] === "hour") return { date: new Date(now.getTime() + n * 3600 * 1000), consumed: 3 }; - } - - if ((low[0] === "next" || low[0] === "this") && WEEKDAY_MAP[low[1] ?? ""] !== undefined) { - return { date: nextWeekday(now, WEEKDAY_MAP[low[1]], low[0] === "this"), consumed: 2 }; - } - - if (low[0] === "next" && low[1] === "week") return { date: addDays(startOfDay(now), 7), consumed: 2 }; - if (low[0] === "next" && low[1] === "month") return { date: addMonths(startOfDay(now), 1), consumed: 2 }; - - if (low[0] === "end" && low[1] === "of") { - if (low[2] === "month") { - const sod = startOfDay(now); - return { date: new Date(sod.getFullYear(), sod.getMonth() + 1, 0), consumed: 3 }; - } - if (low[2] === "week") return { date: nextWeekday(now, 6, false), consumed: 3 }; - if (low[2] === "day") return { date: startOfDay(now), consumed: 3 }; - } - - if (WEEKDAY_MAP[low[0]] !== undefined) { - return { date: nextWeekday(now, WEEKDAY_MAP[low[0]], false), consumed: 1 }; - } - - return null; -} - -/** Parse a time token ("2pm", "14:30", "9am") into hours/minutes. */ -function parseTime(word: string): { hours: number; minutes: number } | null { - const colon = word.toLowerCase().match(/^([0-2]?\d):(\d{2})\s?(am|pm)?$/); - if (colon) { - let h = num(colon[1]); - const min = num(colon[2]); - const ampm = colon[3]; - if (ampm === "pm" && h < 12) h += 12; - if (ampm === "am" && h === 12) h = 0; - if (h > 23 || min > 59) return null; - return { hours: h, minutes: min }; - } - const bare = word.toLowerCase().match(/^(\d{1,2})(am|pm)$/); - if (bare) { - let h = num(bare[1]); - if (bare[2] === "pm" && h < 12) h += 12; - if (bare[2] === "am" && h === 12) h = 0; - if (h > 23) return null; - return { hours: h, minutes: 0 }; - } - return null; -} - -// ── Main parser ─────────────────────────────────────────────────────────────── - -/** - * Parse a quick-add string into structured task data. - * - * `context` supplies the user's known project/tag names so `#proj` and `@tag` - * tokens can be matched (the matched NAME is returned; the caller resolves it - * to an id). Unknown tokens are left in the title so nothing is silently lost. - */ -export function parseQuickAdd(input: string, context?: QuickAddContext): ParsedTask { - const tokens = tokenize(input); - // Date.now() (rather than `new Date()`) so tests can pin "now" by patching - // Date.now; the production path is unaffected. - const now = new Date(Date.now()); - - let priority: ParsedTask["priority"]; - let project: string | undefined; - const tags: string[] = []; - let dueDate: Date | undefined; - let recurrence: string | undefined; - // Track which project/tag tokens were resolved so unmatched ones stay in the - // title instead of being silently dropped. - const resolvedProjects = new Set(); - const resolvedTags = new Set(); - - const words = tokens.map((t) => t.value); - - // Pass 1: recurrences (multi-word, e.g. "every monday"). Run before dates so - // a bare weekday inside "every monday" is not mistaken for a one-off date. - let i = 0; - while (i < tokens.length) { - if (tokens[i].kind === "word") { - const rec = parseRecurrence(words.slice(i, i + 6)); - if (rec) { - recurrence = rec.rrule; - for (let k = i; k < i + rec.consumed; k++) tokens[k] = { kind: "recurrence", value: tokens[k].value }; - i += rec.consumed; - continue; - } - } - i += 1; - } - - // Pass 2: everything else. - i = 0; - while (i < tokens.length) { - const t = tokens[i]; - - if (t.kind === "priority") { - priority = t.value as ParsedTask["priority"]; - } else if (t.kind === "project") { - const match = context?.projectNames?.find((p) => p.toLowerCase() === t.value.toLowerCase()); - if (match) { - project = match; - resolvedProjects.add(t.value); - } - } else if (t.kind === "tag") { - const match = context?.tagNames?.find((tg) => tg.toLowerCase() === t.value.toLowerCase()); - if (match) { - tags.push(match); - resolvedTags.add(t.value); - } - } else if (t.kind === "date") { - const parsed = parseSingleWordDate(t.value, now); - if (parsed) dueDate = parsed; - } else if (t.kind === "time") { - const parsed = parseTime(t.value); - if (parsed) { - const base = dueDate ? new Date(dueDate) : startOfDay(now); - base.setHours(parsed.hours, parsed.minutes, 0, 0); - dueDate = base; - } - } else if (t.kind === "word") { - const window = words.slice(i, i + 4); - - // "at 2pm" — apply the time to the resolved (or today's) due date. - if (window[0] === "at" && parseTime(window[1] ?? "")) { - const time = parseTime(window[1])!; - const base = dueDate ? new Date(dueDate) : startOfDay(now); - base.setHours(time.hours, time.minutes, 0, 0); - dueDate = base; - tokens[i] = { kind: "time", value: window[0] }; - tokens[i + 1] = { kind: "time", value: window[1] }; - i += 2; - continue; - } - - const monthDay = parseMonthDay(window, now); - if (monthDay) { - dueDate = monthDay.date; - for (let k = i; k < i + monthDay.consumed; k++) tokens[k] = { kind: "date", value: tokens[k].value }; - i += monthDay.consumed; - continue; - } - - const phrase = parseDatePhrase(window, now); - if (phrase) { - dueDate = phrase.date; - for (let k = i; k < i + phrase.consumed; k++) tokens[k] = { kind: "date", value: tokens[k].value }; - i += phrase.consumed; - continue; - } - } - - i += 1; - } - - // Rebuild the title from the remaining tokens, in order: plain words, plus - // any project/tag tokens that did not resolve to a known name (their original - // # / @ prefix is restored so nothing is silently dropped). - const title = tokens - .filter((t) => { - if (t.kind === "word") return true; - if (t.kind === "project") return !resolvedProjects.has(t.value); - if (t.kind === "tag") return !resolvedTags.has(t.value); - return false; - }) - .map((t) => { - if (t.kind === "project" && !resolvedProjects.has(t.value)) return `#${t.value}`; - if (t.kind === "tag" && !resolvedTags.has(t.value)) return `@${t.value}`; - return t.value; - }) - .join(" ") - .trim(); - - return { - title, - dueDate, - priority, - project, - tags: tags.length > 0 ? tags : undefined, - recurrence, - raw: input, - }; -} diff --git a/apps/web/src/lib/types/index.ts b/apps/web/src/lib/types/index.ts index 545fa2f..12f32f3 100644 --- a/apps/web/src/lib/types/index.ts +++ b/apps/web/src/lib/types/index.ts @@ -9,9 +9,13 @@ export interface Task { domainId: string; projectId: string | null; sectionId: string | null; + stateId: string | null; + moduleId: string | null; + cycleId: string | null; parentId: string | null; dueDate: string | null; estimatedMinutes: number | null; + trackedMinutes: number | null; recurrenceRule: string | null; order: number; completedAt: string | null; @@ -25,6 +29,61 @@ export interface Task { dependents?: { id: string; title: string; status: string }[]; } +export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; + +export interface State { + id: string; + name: string; + color: string | null; + group: StateGroup; + projectId: string; + sortOrder: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export type ModuleStatus = "planned" | "in_progress" | "completed" | "cancelled"; + +export interface Module { + id: string; + name: string; + description: string | null; + projectId: string; + status: ModuleStatus; + startDate: string | null; + targetDate: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + tasks?: Task[]; +} + +export interface Cycle { + id: string; + name: string; + projectId: string; + startDate: string | null; + endDate: string | null; + active: boolean; + createdAt: string; + updatedAt: string; +} + +export type LinkType = "relates" | "blocks" | "parent-child" | "created-from"; + +export interface Link { + id: string; + sourceType: string; + sourceId: string; + targetType: string; + targetId: string; + linkType: LinkType; + direction: string | null; + createdAt: string; +} + export interface Habit { id: string; name: string; diff --git a/apps/web/src/routes/_app/canvas/$id.tsx b/apps/web/src/routes/_app/canvas/$id.tsx deleted file mode 100644 index 210e2d9..0000000 --- a/apps/web/src/routes/_app/canvas/$id.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { createRoute, useParams, useNavigate } from "@tanstack/react-router"; -import { Route as appRoute } from "../../_app"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { Calendar, Clock, FileText, LayoutDashboard, Trash2 } from "lucide-react"; -import { format, parseISO } from "date-fns"; -import { api, useApiQuery } from "@/lib/api"; -import { useRealtime } from "@/hooks/use-realtime"; -import { useOptimisticPatch } from "@/hooks/use-optimistic-patch"; -import { InlineTextarea } from "@/components/entities/inline-edit"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { LoadingState, ErrorState } from "@/components/state"; -import type { Canvas } from "@/lib/types"; -import { CanvasEditor } from "../canvas"; - -function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : "Something went wrong"; -} - -function formatCustomFieldValue(value: unknown): string { - if (value === null || value === undefined) return "—"; - if (typeof value === "object") return JSON.stringify(value); - return String(value); -} - -function CanvasDetail() { - const { id } = useParams({ from: Route.id }); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - - useRealtime({ enabled: true }); - - const { data: canvas, isLoading, isError, error, refetch } = useApiQuery( - ["canvas", id], - "/canvas/" + id - ); - - const { patch } = useOptimisticPatch({ - entityKey: ["canvas", id], - listKeys: [["canvas"]], - patchUrl: (cid) => `/canvas/${cid}`, - applyPatch: (current, data) => ({ ...current, ...data }), - }); - - const deleteMutation = useMutation({ - mutationFn: () => api.delete(`/canvas/${id}`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["canvas"] }); - toast.success("Canvas deleted"); - navigate({ to: "/canvas" }); - }, - onError: (err) => toast.error(errorMessage(err)), - }); - - if (isLoading) return ; - if (isError) { - return refetch()} />; - } - if (!canvas) return ; - - const customFieldEntries = Object.entries(canvas.customFields ?? {}); - - return ( -
-
-
- navigate({ to: "/canvas" })} /> -
- -
-
- ); -} - -export const Route = createRoute({ - getParentRoute: () => appRoute, - path: "canvas/$id", - component: CanvasDetail, -}); diff --git a/apps/web/src/routes/_app/graph.tsx b/apps/web/src/routes/_app/graph.tsx index 6e0819e..1c7eceb 100644 --- a/apps/web/src/routes/_app/graph.tsx +++ b/apps/web/src/routes/_app/graph.tsx @@ -20,7 +20,7 @@ import type { GraphNode, GraphEdge } from "@/lib/types"; import ForceGraph2D from "react-force-graph-2d"; const ENTITY_TYPES = ["task", "habit", "project", "note", "section", "tag", "domain"]; -const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to"]; +const RELATIONSHIP_TYPES = ["depends_on", "related_to", "part_of", "references", "parent_of", "child_of", "connects_to", "relates", "blocks", "parent-child", "created-from", "task_project", "task_domain", "habit_domain", "project_domain", "note_domain", "section_project"]; const ENTITY_COLORS: Record = { task: "#3b82f6", @@ -32,6 +32,20 @@ const ENTITY_COLORS: Record = { domain: "#6366f1", }; +const LINK_TYPE_COLORS: Record = { + relates: "#94a3b8", + blocks: "#ef4444", + "parent-child": "#8b5cf6", + "created-from": "#10b981", + depends_on: "#ef4444", + related_to: "#94a3b8", + part_of: "#8b5cf6", + references: "#f59e0b", + parent_of: "#8b5cf6", + child_of: "#10b981", + connects_to: "#3b82f6", +}; + // Graph node types that have a detail page. section/tag/domain nodes appear in // the graph but have no detail route, so they are intentionally absent. const NODE_TYPE_ROUTES: Record = { @@ -265,11 +279,17 @@ function GraphPage() { const isHighlighted = highlightLinks.size === 0 || highlightLinks.has(`${link.source.id}-${link.target.id}`); const width = isHighlighted ? 1.5 / globalScale : 0.5 / globalScale; const opacity = isHighlighted ? 0.6 : 0.1; + const linkType = link.type || "relates"; + const baseColor = LINK_TYPE_COLORS[linkType] || "#94a3b8"; + + const r = parseInt(baseColor.slice(1, 3), 16); + const g = parseInt(baseColor.slice(3, 5), 16); + const b = parseInt(baseColor.slice(5, 7), 16); ctx.beginPath(); ctx.moveTo(link.source.x, link.source.y); ctx.lineTo(link.target.x, link.target.y); - ctx.strokeStyle = `rgba(148, 163, 184, ${opacity})`; + ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`; ctx.lineWidth = width; ctx.stroke(); @@ -289,7 +309,7 @@ function GraphPage() { ctx.lineTo(midX - ux * arrowSize + uy * arrowSize * 0.5, midY - uy * arrowSize - ux * arrowSize * 0.5); ctx.lineTo(midX - ux * arrowSize - uy * arrowSize * 0.5, midY - uy * arrowSize + ux * arrowSize * 0.5); ctx.closePath(); - ctx.fillStyle = `rgba(148, 163, 184, ${opacity})`; + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`; ctx.fill(); } } @@ -425,8 +445,9 @@ function GraphPage() { checked={enabledRelationships.has(type)} onCheckedChange={() => toggleRelationship(type)} /> -