diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a5fac02..023d620 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,7 @@ import { domainRoutes } from "./routes/domains"; import { taskRoutes } from "./routes/tasks"; import { habitRoutes } from "./routes/habits"; import { projectRoutes } from "./routes/projects"; +import { stateRoutes } from "./routes/states"; import { noteRoutes } from "./routes/notes"; import { searchRoutes } from "./routes/search"; import { calendarRoutes } from "./routes/calendar"; @@ -47,6 +48,7 @@ app.route("/api/domains", domainRoutes); app.route("/api/tasks", taskRoutes); app.route("/api/habits", habitRoutes); app.route("/api/projects", projectRoutes); +app.route("/api/states", stateRoutes); app.route("/api/notes", noteRoutes); app.route("/api/search", searchRoutes); app.route("/api/calendar", calendarRoutes); diff --git a/apps/api/src/lib/automation-engine.ts b/apps/api/src/lib/automation-engine.ts new file mode 100644 index 0000000..b8ee784 --- /dev/null +++ b/apps/api/src/lib/automation-engine.ts @@ -0,0 +1,291 @@ +import { db, automationRules, tasks, taskTags, tags as tagsTable, statusDefinitions } from "@project-e/db"; +import { and, eq, isNull } from "drizzle-orm"; +import { recordActivity } from "../middleware/activity"; +import { enqueueWebhooks } from "../middleware/webhook-queue"; +import { notifyWorkspaceOwner } from "./notify"; + +export const TRIGGER_TYPES = ["task_status_changed", "task_created", "due_date_approaching"] as const; +export const ACTION_TYPES = ["set_status", "set_priority", "add_label", "create_notification"] as const; + +// Safety limit: cap the number of actions executed per trigger event so a +// misconfigured rule can never cascade into runaway writes. +const MAX_ACTIONS_PER_EVENT = 10; + +export interface EvaluateAutomationsParams { + projectId: string | null | undefined; + triggerType: string; + /** The affected entity row (e.g. the task after its mutation). */ + entity: Record; + /** 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 new file mode 100644 index 0000000..523950b --- /dev/null +++ b/apps/api/src/lib/notify.ts @@ -0,0 +1,65 @@ +import { db, sql, notifications, domains } from "@project-e/db"; +import { eq } from "drizzle-orm"; + +export type NotificationType = + | "mention" + | "status_change" + | "due_soon" + | "automation" + | "assignment"; + +export interface CreateNotificationParams { + userId: string; + workspaceId?: string | null; + type: NotificationType | string; + title: string; + body?: string | null; + entityType?: string | null; + entityId?: string | null; +} + +/** + * Insert an in-app notification and fan it out over the realtime SSE stream so + * open clients refresh their bell count and list without polling. The event + * mirrors the pg_notify shape used by recordActivity: `{ type, action, id, + * workspace_id }` with `type: "notification"`. + */ +export async function createNotification(params: CreateNotificationParams): Promise { + 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/automations.ts b/apps/api/src/routes/automations.ts new file mode 100644 index 0000000..53179f9 --- /dev/null +++ b/apps/api/src/routes/automations.ts @@ -0,0 +1,309 @@ +import { Hono } from "hono"; +import { db, automationRules, projects } from "@project-e/db"; +import { and, asc, eq, isNull } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { enqueueWebhooks } from "../middleware/webhook-queue"; +import { z } from "zod"; + +export const automationRoutes = new Hono(); + +const TRIGGER_TYPES = ["task_status_changed", "task_created", "due_date_approaching"] as const; +const ACTION_TYPES = ["set_status", "set_priority", "add_label", "create_notification"] as const; +const PRIORITIES = ["low", "medium", "high", "urgent"] as const; + +const triggerSchema = z.object({ + type: z.enum(TRIGGER_TYPES), + params: z.record(z.string(), z.unknown()).optional(), +}); + +const conditionSchema = z.object({ + field: z.enum(["project", "status", "priority", "label"]), + op: z.string().min(1), + value: z.unknown(), +}); + +const actionSchema = z.object({ + type: z.enum(ACTION_TYPES), + params: z.record(z.string(), z.unknown()).optional(), +}); + +const createAutomationSchema = z.object({ + name: z.string().min(1, "Name is required").max(120), + active: z.boolean().optional(), + trigger: triggerSchema, + conditions: z.array(conditionSchema).optional(), + actions: z.array(actionSchema).min(1, "At least one action is required"), +}); + +const updateAutomationSchema = createAutomationSchema.partial(); + +// Validate action params against what the evaluation engine expects, so a bad +// rule surfaces at save time instead of silently doing nothing at run time. +function validateActionParams(actions: { type: string; params?: Record }[]): string | 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/graph.ts b/apps/api/src/routes/graph.ts index 60ab574..ceba2b5 100644 --- a/apps/api/src/routes/graph.ts +++ b/apps/api/src/routes/graph.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { db, domains, notes, noteLinks, noteEntityLinks, tasks, taskDependencies, habits, projects, sections, tags as tagsTable } from "@project-e/db"; +import { db, domains, notes, tasks, habits, projects, sections, tags as tagsTable, links } from "@project-e/db"; import { and, eq, inArray, isNull } from "drizzle-orm"; import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; @@ -57,18 +57,12 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg for (const s of sectionRows) addNode(s.id, s.name, 'section'); for (const t of tagRows) addNode(t.id, t.name, 'tag'); - const noteIds = noteRows.map(n => n.id); - if (noteIds.length > 0) { - const linkRows = await db.select().from(noteLinks).where(inArray(noteLinks.sourceNoteId, noteIds)); - for (const l of linkRows) addEdge(l.sourceNoteId, l.targetNoteId, 'note_link'); - const entityLinkRows = await db.select().from(noteEntityLinks).where(inArray(noteEntityLinks.noteId, noteIds)); - for (const l of entityLinkRows) addEdge(l.noteId, l.entityId, 'note_' + l.entityType); - } - - const taskIds = taskRows.map(t => t.id); - if (taskIds.length > 0) { - const depRows = await db.select().from(taskDependencies).where(inArray(taskDependencies.taskId, taskIds)); - for (const d of depRows) addEdge(d.taskId, d.dependsOnTaskId, 'depends_on'); + // Read links from the canonical links table + const allIds = [...noteRows.map(n => n.id), ...taskRows.map(t => t.id)]; + if (allIds.length > 0) { + const linkRows = await db.select().from(links) + .where(inArray(links.sourceId, allIds)); + for (const l of linkRows) addEdge(l.sourceId, l.targetId, l.linkType); } for (const t of taskRows) { if (t.projectId) addEdge(t.id, t.projectId, 'task_project'); addEdge(t.id, domainId, 'task_domain'); } @@ -80,20 +74,6 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg return { nodes, edges }; } -// Resolve the owning domain for a graph edge source. `type` may be an edge type -// (note_link / note_entity / task_dependency) or a source entity type (note / task). -async function resolveEdgeWorkspaceId(sourceId: string, type: string): Promise { - if (type === "note_link" || type === "note_entity" || type === "note") { - const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, sourceId)).limit(1); - return row?.domainId ?? null; - } - if (type === "task_dependency" || type === "task") { - const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, sourceId)).limit(1); - return row?.domainId ?? null; - } - return null; -} - // GET /api/graph/nodes — All nodes graphRoutes.get("/nodes", async (c) => { try { @@ -136,41 +116,34 @@ graphRoutes.get("/edges", async (c) => { } }); -// POST /api/graph/edges — Create a relationship (note link) +// POST /api/graph/edges — Create a relationship via the links table graphRoutes.post("/edges", async (c) => { try { const user = await requireAuth(c); const body = await c.req.json(); - const { sourceId, targetId, type } = z.object({ + const { sourceId, targetId, type, sourceType, targetType } = z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid(), - type: z.string().default("note_link"), + type: z.string().default("relates"), + sourceType: z.string().default("note"), + targetType: z.string().default("note"), }).parse(body); - // Verify ownership before mutating anything. Both endpoints of the edge - // must belong to the caller's domain. - const workspaceId = await resolveEdgeWorkspaceId(sourceId, type); + const workspaceId = await resolveEdgeWorkspaceId(sourceId, sourceType); if (workspaceId) { await requireWorkspaceAccess(c, workspaceId); } - const targetType = type === "note_link" ? "note" : (type === "note_entity" || type === "task_dependency") ? "task" : type; - const targetWorkspaceId = await resolveEdgeWorkspaceId(targetId, targetType); - if (targetWorkspaceId) { - await requireWorkspaceAccess(c, targetWorkspaceId); - } - if (type === "note_link") { - await db.insert(noteLinks).values({ sourceNoteId: sourceId, targetNoteId: targetId }); - } else if (type === "note_entity") { - await db.insert(noteEntityLinks).values({ noteId: sourceId, entityType: "task", entityId: targetId }); - } else if (type === "task_dependency") { - await db.insert(taskDependencies).values({ taskId: sourceId, dependsOnTaskId: targetId }); - } else { - return c.json({ error: { code: "VALIDATION_ERROR", message: "Unknown edge type: " + type } }, 400); - } + await db.insert(links).values({ + sourceType, + sourceId, + targetType, + targetId, + linkType: type as any, + }); if (!workspaceId) { - console.warn(`[graph] POST /edges: could not resolve workspace for source ${sourceId} (type ${type}); skipping activity`); + console.warn(`[graph] POST /edges: could not resolve workspace for source ${sourceId} (type ${sourceType}); skipping activity`); } else { await recordActivity({ actor: user.name, @@ -195,39 +168,20 @@ graphRoutes.post("/edges", async (c) => { } }); -// DELETE /api/graph/edges/:id — Remove +// DELETE /api/graph/edges/:id — Remove via links table graphRoutes.delete("/edges/:id", async (c) => { try { const user = await requireAuth(c); const id = c.req.param("id"); const [sourceId, targetId] = id.split("-"); - // The type isn't known at delete time, so resolve from the source entity: - // it's either a note or a task. Verify ownership before mutating anything. - let workspaceId = await resolveEdgeWorkspaceId(sourceId, "note"); - if (!workspaceId) { - workspaceId = await resolveEdgeWorkspaceId(sourceId, "task"); - } + const workspaceId = await resolveEdgeWorkspaceId(sourceId, "note") || await resolveEdgeWorkspaceId(sourceId, "task"); if (workspaceId) { await requireWorkspaceAccess(c, workspaceId); } - // Try deleting from note_links first - const result = await db.delete(noteLinks) - .where(and(eq(noteLinks.sourceNoteId, sourceId), eq(noteLinks.targetNoteId, targetId))) - .returning(); - - if (result.length === 0) { - // Try note_entity_links (note → entity edges) - const entityResult = await db.delete(noteEntityLinks) - .where(and(eq(noteEntityLinks.noteId, sourceId), eq(noteEntityLinks.entityId, targetId))) - .returning(); - if (entityResult.length === 0) { - // Try task_dependencies - await db.delete(taskDependencies) - .where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId))); - } - } + await db.delete(links) + .where(and(eq(links.sourceId, sourceId), eq(links.targetId, targetId))); if (!workspaceId) { console.warn(`[graph] DELETE /edges/${id}: could not resolve workspace for source ${sourceId}; skipping activity`); @@ -251,3 +205,15 @@ graphRoutes.delete("/edges/:id", async (c) => { return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete edge" } }, 500); } }); + +async function resolveEdgeWorkspaceId(sourceId: string, type: string): Promise { + if (type === "note") { + const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, sourceId)).limit(1); + return row?.domainId ?? null; + } + if (type === "task") { + const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, sourceId)).limit(1); + return row?.domainId ?? null; + } + return null; +} diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index 039fe37..0cacc62 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { createHash } from "node:crypto"; -import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; +import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"; import { recordActivity } from "../middleware/activity"; @@ -95,7 +95,7 @@ const tools: ToolDefinition[] = [ eq(tasks.domainId, params.domain_id as string), isNull(tasks.deletedAt), ]; - if (params.status) conditions.push(eq(tasks.status, params.status as any)); + // TODO(phase-2): filter by state_group / state_id instead of old status if (params.priority) conditions.push(eq(tasks.priority, params.priority as any)); if (params.project_id) conditions.push(eq(tasks.projectId, params.project_id as string)); if (params.search) conditions.push(ilike(tasks.title, `%${params.search}%`)); @@ -119,7 +119,7 @@ const tools: ToolDefinition[] = [ domain_id: { type: "string", description: "Workspace/domain ID" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + status: { type: "string" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, project_id: { type: "string" }, @@ -130,7 +130,6 @@ const tools: ToolDefinition[] = [ const [task] = await db.insert(tasks).values({ title: params.title as string, description: (params.description as string) ?? null, - status: (params.status as any) ?? "todo", priority: (params.priority as any) ?? "medium", domainId: params.domain_id as string, projectId: (params.project_id as string) ?? null, @@ -142,7 +141,7 @@ const tools: ToolDefinition[] = [ action: "created", entityType: "task", entityId: task.id, - changes: { title: task.title, status: task.status }, + changes: { title: task.title }, workspaceId: params.domain_id as string, }); @@ -158,7 +157,7 @@ const tools: ToolDefinition[] = [ task_id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, - status: { type: "string", enum: ["todo", "in_progress", "done", "cancelled"] }, + status: { type: "string" }, priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, due_date: { type: "string" }, }, @@ -172,7 +171,6 @@ const tools: ToolDefinition[] = [ const updateData: Record = {}; if (params.title !== undefined) updateData.title = params.title; if (params.description !== undefined) updateData.description = params.description; - if (params.status !== undefined) updateData.status = params.status; if (params.priority !== undefined) updateData.priority = params.priority; if (params.due_date !== undefined) updateData.dueDate = params.due_date ? new Date(params.due_date as string) : null; updateData.updatedAt = new Date(); @@ -237,7 +235,7 @@ const tools: ToolDefinition[] = [ await verifyDomainAccess(existing.domainId, auth.userId); const [task] = await db.update(tasks) - .set({ status: "done", completedAt: new Date(), updatedAt: new Date() }) + .set({ completedAt: new Date(), updatedAt: new Date() }) .where(and(eq(tasks.id, params.task_id as string), isNull(tasks.deletedAt))) .returning(); @@ -566,7 +564,7 @@ const tools: ToolDefinition[] = [ const results: Record = {}; if (types.includes("tasks")) { - results.tasks = await db.select({ id: tasks.id, title: tasks.title, status: tasks.status, priority: tasks.priority }).from(tasks) + results.tasks = await db.select({ id: tasks.id, title: tasks.title, priority: tasks.priority }).from(tasks) .where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt), ilike(tasks.title, `%${query}%`))).limit(limit); } if (types.includes("notes")) { diff --git a/apps/api/src/routes/note-link-service.ts b/apps/api/src/routes/note-link-service.ts index 070da31..304e8b2 100644 --- a/apps/api/src/routes/note-link-service.ts +++ b/apps/api/src/routes/note-link-service.ts @@ -1,13 +1,13 @@ /** * Note Link Service * - * Handles wikilink resolution and note_links / note_entity_links management. + * Handles wikilink resolution and links management. * On note save, parses content for [[wikilinks]], resolves each to a note_id or entity_id, * and diffs the existing links to produce idempotent deletes+inserts. */ -import { db, noteLinks, noteEntityLinks, notes, tasks, habits, projects, sections, tags as tagsTable } from "@project-e/db"; -import { and, eq, inArray, isNull, desc } from "drizzle-orm"; +import { db, links, notes, tasks, habits, projects, sections, tags as tagsTable } from "@project-e/db"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { extractLinkTargets } from "./wikilink-parser"; /** @@ -103,59 +103,39 @@ export async function syncNoteLinks(noteId: string, content: string, domainId: s } } - const noteToNoteLinks = resolvedTargets.filter(t => t.entityType === "note"); - const entityLinks = resolvedTargets.filter(t => t.entityType !== "note"); + // --- Sync all links from this note via the canonical links table --- + const existingLinks = await db + .select({ targetId: links.targetId, targetType: links.targetType }) + .from(links) + .where(and(eq(links.sourceId, noteId), eq(links.sourceType, "note"))); - // --- Sync note_links --- - const existingNoteLinks = await db - .select({ targetNoteId: noteLinks.targetNoteId }) - .from(noteLinks) - .where(eq(noteLinks.sourceNoteId, noteId)); + const existingKeySet = new Set(existingLinks.map(l => `${l.targetType}:${l.targetId}`)); + const newKeySet = new Set(resolvedTargets.map(l => `${l.entityType}:${l.entityId}`)); - const existingTargetIds = new Set(existingNoteLinks.map(l => l.targetNoteId)); - const newTargetIds = new Set(noteToNoteLinks.map(l => l.entityId)); - - const staleTargetIds = [...existingTargetIds].filter(id => !newTargetIds.has(id)); - if (staleTargetIds.length > 0) { + // Delete stale links + const staleLinks = existingLinks.filter(l => !newKeySet.has(`${l.targetType}:${l.targetId}`)); + if (staleLinks.length > 0) { + const staleIds = staleLinks.map(l => l.targetId); await db - .delete(noteLinks) + .delete(links) .where(and( - eq(noteLinks.sourceNoteId, noteId), - inArray(noteLinks.targetNoteId, staleTargetIds), + eq(links.sourceId, noteId), + eq(links.sourceType, "note"), + inArray(links.targetId, staleIds), )); } - const missingTargetIds = [...newTargetIds].filter(id => !existingTargetIds.has(id)); - if (missingTargetIds.length > 0) { - await db.insert(noteLinks).values( - missingTargetIds.map(targetNoteId => ({ sourceNoteId: noteId, targetNoteId })) - ); - } - - // --- Sync note_entity_links --- - const existingEntityLinks = await db - .select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId }) - .from(noteEntityLinks) - .where(eq(noteEntityLinks.noteId, noteId)); - - const existingEntityKeySet = new Set(existingEntityLinks.map(l => `${l.entityType}:${l.entityId}`)); - const newEntityKeySet = new Set(entityLinks.map(l => `${l.entityType}:${l.entityId}`)); - - const staleEntityLinks = existingEntityLinks.filter(l => !newEntityKeySet.has(`${l.entityType}:${l.entityId}`)); - for (const link of staleEntityLinks) { - await db - .delete(noteEntityLinks) - .where(and( - eq(noteEntityLinks.noteId, noteId), - eq(noteEntityLinks.entityType, link.entityType), - eq(noteEntityLinks.entityId, link.entityId), - )); - } - - const missingEntityLinks = entityLinks.filter(l => !existingEntityKeySet.has(`${l.entityType}:${l.entityId}`)); - if (missingEntityLinks.length > 0) { - await db.insert(noteEntityLinks).values( - missingEntityLinks.map(l => ({ noteId, entityType: l.entityType, entityId: l.entityId })) + // Insert missing links + const missingTargets = resolvedTargets.filter(l => !existingKeySet.has(`${l.entityType}:${l.entityId}`)); + if (missingTargets.length > 0) { + await db.insert(links).values( + missingTargets.map(l => ({ + sourceType: "note", + sourceId: noteId, + targetType: l.entityType, + targetId: l.entityId, + linkType: "relates" as const, + })) ); } } @@ -170,10 +150,12 @@ export async function getBacklinks(noteId: string): Promise<{ id: string; title: title: notes.title, content: notes.content, }) - .from(noteLinks) - .innerJoin(notes, eq(noteLinks.sourceNoteId, notes.id)) + .from(links) + .innerJoin(notes, eq(links.sourceId, notes.id)) .where(and( - eq(noteLinks.targetNoteId, noteId), + eq(links.targetId, noteId), + eq(links.sourceType, "note"), + eq(links.targetType, "note"), isNull(notes.deletedAt), )); @@ -205,51 +187,52 @@ export async function getOutgoingLinks(noteId: string): Promise<{ noteLinks: { id: string; title: string }[]; entityLinks: { entityType: string; entityId: string; title: string | null }[]; }> { - const noteLinkRows = await db - .select({ id: notes.id, title: notes.title }) - .from(noteLinks) - .innerJoin(notes, eq(noteLinks.targetNoteId, notes.id)) - .where(and( - eq(noteLinks.sourceNoteId, noteId), - isNull(notes.deletedAt), - )); - - const entityLinkRows = await db - .select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId }) - .from(noteEntityLinks) - .where(eq(noteEntityLinks.noteId, noteId)); + const outgoingLinks = await db + .select({ targetId: links.targetId, targetType: links.targetType }) + .from(links) + .where(and(eq(links.sourceId, noteId), eq(links.sourceType, "note"))); + const noteLinkRows: { id: string; title: string }[] = []; const entityLinksWithTitles: { entityType: string; entityId: string; title: string | null }[] = []; - for (const link of entityLinkRows) { - let title: string | null = null; - switch (link.entityType) { - case "task": { - const [t] = await db.select({ title: tasks.title }).from(tasks).where(eq(tasks.id, link.entityId)).limit(1); - title = t?.title ?? null; - break; - } - case "habit": { - const [h] = await db.select({ name: habits.name }).from(habits).where(eq(habits.id, link.entityId)).limit(1); - title = h?.name ?? null; - break; - } - case "project": { - const [p] = await db.select({ name: projects.name }).from(projects).where(eq(projects.id, link.entityId)).limit(1); - title = p?.name ?? null; - break; - } - case "section": { - const [s] = await db.select({ name: sections.name }).from(sections).where(eq(sections.id, link.entityId)).limit(1); - title = s?.name ?? null; - break; - } - case "tag": { - const [t] = await db.select({ name: tagsTable.name }).from(tagsTable).where(eq(tagsTable.id, link.entityId)).limit(1); - title = t?.name ?? null; - break; + + for (const link of outgoingLinks) { + if (link.targetType === "note") { + const [note] = await db.select({ id: notes.id, title: notes.title }) + .from(notes) + .where(and(eq(notes.id, link.targetId), isNull(notes.deletedAt))) + .limit(1); + if (note) noteLinkRows.push({ id: note.id, title: note.title }); + } else { + let title: string | null = null; + switch (link.targetType) { + case "task": { + const [t] = await db.select({ title: tasks.title }).from(tasks).where(eq(tasks.id, link.targetId)).limit(1); + title = t?.title ?? null; + break; + } + case "habit": { + const [h] = await db.select({ name: habits.name }).from(habits).where(eq(habits.id, link.targetId)).limit(1); + title = h?.name ?? null; + break; + } + case "project": { + const [p] = await db.select({ name: projects.name }).from(projects).where(eq(projects.id, link.targetId)).limit(1); + title = p?.name ?? null; + break; + } + case "section": { + const [s] = await db.select({ name: sections.name }).from(sections).where(eq(sections.id, link.targetId)).limit(1); + title = s?.name ?? null; + break; + } + case "tag": { + const [t] = await db.select({ name: tagsTable.name }).from(tagsTable).where(eq(tagsTable.id, link.targetId)).limit(1); + title = t?.name ?? null; + break; + } } + entityLinksWithTitles.push({ entityType: link.targetType, entityId: link.targetId, title }); } - entityLinksWithTitles.push({ entityType: link.entityType, entityId: link.entityId, title }); } return { diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index 3ea3294..62770e5 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; -import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db"; -import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"; +import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed, states } from "@project-e/db"; +import { and, asc, desc, eq, ilike, inArray, isNull, isNotNull, sql } from "drizzle-orm"; import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { enqueueWebhooks } from "../middleware/webhook-queue"; @@ -144,7 +144,7 @@ projectRoutes.get("/", async (c) => { const [completedResult] = await db.select({ count: sql`count(*)` }) .from(tasks) - .where(and(eq(tasks.projectId, projectId), eq(tasks.status, "done"), isNull(tasks.deletedAt))); + .where(and(eq(tasks.projectId, projectId), isNotNull(tasks.completedAt), isNull(tasks.deletedAt))); taskCountMap.set(projectId, { total: Number(totalResult?.count || 0), @@ -210,6 +210,24 @@ projectRoutes.post("/", async (c) => { ); } + // Seed 5 default workflow states for the new project (Decision 10) + const defaultStates = [ + { name: 'Backlog', color: '#94a3b8', group: 'backlog' as const, sortOrder: 0 }, + { name: 'Todo', color: '#60a5fa', group: 'unstarted' as const, sortOrder: 1 }, + { name: 'In Progress', color: '#facc15', group: 'started' as const, sortOrder: 2 }, + { name: 'Done', color: '#4ade80', group: 'completed' as const, sortOrder: 3 }, + { name: 'Cancelled', color: '#f87171', group: 'cancelled' as const, sortOrder: 4 }, + ]; + await db.insert(states).values( + defaultStates.map(s => ({ + name: s.name, + color: s.color, + group: s.group, + sortOrder: s.sortOrder, + projectId: project.id, + })) + ); + await recordActivity({ actor: user.name, action: "created", @@ -277,7 +295,7 @@ projectRoutes.get("/:id", async (c) => { .where(eq(projectTags.projectId, id)); const totalTasks = projectTasks.length; - const completedTasks = projectTasks.filter(t => t.status === "done").length; + const completedTasks = projectTasks.filter(t => t.completedAt !== null).length; const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; return c.json({ diff --git a/apps/api/src/routes/states.ts b/apps/api/src/routes/states.ts new file mode 100644 index 0000000..b01efc4 --- /dev/null +++ b/apps/api/src/routes/states.ts @@ -0,0 +1,352 @@ +import { Hono } from "hono"; +import { db, states, projects } from "@project-e/db"; +import { asc, eq, 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 stateRoutes = new Hono(); + +const stateGroupEnum = z.enum(["backlog", "unstarted", "started", "completed", "cancelled"]); + +const createStateSchema = z.object({ + projectId: z.string().uuid("Invalid project id"), + name: z.string().min(1, "Name is required"), + group: stateGroupEnum.optional().default("unstarted"), + color: z.string().nullable().optional(), + sortOrder: z.number().int().min(0).optional(), +}); + +const updateStateSchema = z.object({ + name: z.string().min(1).optional(), + color: z.string().nullable().optional(), + group: stateGroupEnum.optional(), + sortOrder: z.number().int().min(0).optional(), +}); + +const reorderSchema = z.object({ + projectId: z.string().uuid("Invalid project id"), + orderedIds: z.array(z.string().uuid("Invalid state id")), +}); + +/** + * Resolve a project and verify the user has access to the owning workspace. + * Returns the project row on success, throws AuthError otherwise. + */ +async function resolveProject(c: any, projectId: string, user: { name: string }) { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!project) { + throw new AuthError("Project not found", 404, "NOT_FOUND"); + } + + await requireWorkspaceAccess(c, project.domainId); + return project; +} + +// POST /api/states/reorder — bulk reorder states within a project +// This MUST be registered before /:id routes to avoid route conflicts. +stateRoutes.post("/reorder", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = reorderSchema.parse(body); + + const project = await resolveProject(c, data.projectId, user); + + // Verify all state IDs belong to this project + const existingStates = await db + .select({ id: states.id }) + .from(states) + .where(eq(states.projectId, data.projectId)); + + const validIds = new Set(existingStates.map((s) => s.id)); + const invalidIds = data.orderedIds.filter((id) => !validIds.has(id)); + if (invalidIds.length > 0) { + return c.json( + { error: { code: "VALIDATION_ERROR", message: `Invalid state ids: ${invalidIds.join(", ")}` } }, + 400 + ); + } + + // Assign sortOrder 0..n-1 in one transaction + await db.transaction(async (tx) => { + for (let i = 0; i < data.orderedIds.length; i++) { + await tx + .update(states) + .set({ sortOrder: i, updatedAt: new Date() }) + .where(eq(states.id, data.orderedIds[i])); + } + }); + + await recordActivity({ + actor: user.name, + action: "reordered", + entityType: "state", + entityId: data.projectId, + changes: { orderedIds: data.orderedIds }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ + workspaceId: project.domainId, + event: "state.reordered", + entityType: "state", + entityId: data.projectId, + data: { orderedIds: data.orderedIds }, + }); + + return c.json({ success: true }); + } 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("[states] POST /reorder error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to reorder states" } }, 500); + } +}); + +// GET /api/states?projectId= — list states for a project +stateRoutes.get("/", async (c) => { + try { + await requireAuth(c); + const url = new URL(c.req.url); + const projectId = url.searchParams.get("projectId"); + + if (!projectId || !isUuid(projectId)) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "A valid projectId query parameter is required" } }, 400); + } + + const project = await resolveProject(c, projectId, { name: "" }); + + const items = await db + .select() + .from(states) + .where(eq(states.projectId, projectId)) + .orderBy(asc(states.sortOrder)); + + 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("[states] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list states" } }, 500); + } +}); + +// POST /api/states — create a state +stateRoutes.post("/", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const data = createStateSchema.parse(body); + + const project = await resolveProject(c, data.projectId, user); + + // If no sortOrder provided, default to max+1 within the project + let sortOrder = data.sortOrder; + if (sortOrder === undefined) { + const [result] = await db + .select({ maxSort: sql`coalesce(max(${states.sortOrder}), -1) + 1` }) + .from(states) + .where(eq(states.projectId, data.projectId)); + sortOrder = result.maxSort; + } + + const [state] = await db + .insert(states) + .values({ + name: data.name, + group: data.group, + color: data.color ?? null, + projectId: data.projectId, + sortOrder, + }) + .returning(); + + await recordActivity({ + actor: user.name, + action: "created", + entityType: "state", + entityId: state.id, + changes: { name: state.name, group: state.group, color: state.color }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ + workspaceId: project.domainId, + event: "state.created", + entityType: "state", + entityId: state.id, + data: { name: state.name, group: state.group }, + }); + + return c.json(state, 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("[states] POST error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create state" } }, 500); + } +}); + +// GET /api/states/:id — get a single state +stateRoutes.get("/:id", async (c) => { + try { + await requireAuth(c); + const id = c.req.param("id"); + if (!isUuid(id)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + + const [state] = await db + .select() + .from(states) + .where(eq(states.id, id)) + .limit(1); + + if (!state) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + + await resolveProject(c, state.projectId, { name: "" }); + + return c.json(state); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[states] GET/:id error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get state" } }, 500); + } +}); + +// PATCH /api/states/:id — update a state +stateRoutes.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 = updateStateSchema.parse(body); + + const [existing] = await db + .select() + .from(states) + .where(eq(states.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + + const project = await resolveProject(c, existing.projectId, user); + + const updateValues: Record = {}; + if (data.name !== undefined) updateValues.name = data.name; + if (data.color !== undefined) updateValues.color = data.color; + if (data.group !== undefined) updateValues.group = data.group; + if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder; + updateValues.updatedAt = new Date(); + + const [updated] = await db + .update(states) + .set(updateValues) + .where(eq(states.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: "updated", + entityType: "state", + entityId: id, + changes: { ...data, previousName: existing.name }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ + workspaceId: project.domainId, + event: "state.updated", + entityType: "state", + entityId: id, + data: { ...data, previousName: existing.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("[states] PATCH error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update state" } }, 500); + } +}); + +// DELETE /api/states/:id — delete a state +// NOTE: The states table has no deleted_at column, so this is a hard delete. +stateRoutes.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(states) + .where(eq(states.id, id)) + .limit(1); + + if (!existing) { + return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404); + } + + const project = await resolveProject(c, existing.projectId, user); + + await db.delete(states).where(eq(states.id, id)); + + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "state", + entityId: id, + changes: { name: existing.name, group: existing.group }, + workspaceId: project.domainId, + }); + + await enqueueWebhooks({ + workspaceId: project.domainId, + event: "state.deleted", + entityType: "state", + 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("[states] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete state" } }, 500); + } +}); diff --git a/apps/api/src/routes/statuses.ts b/apps/api/src/routes/statuses.ts new file mode 100644 index 0000000..f5a30df --- /dev/null +++ b/apps/api/src/routes/statuses.ts @@ -0,0 +1,389 @@ +import { Hono } from "hono"; +import { db, statusDefinitions, tasks, projects } from "@project-e/db"; +import { and, asc, eq, isNull, sql } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; +import { recordActivity } from "../middleware/activity"; +import { enqueueWebhooks } from "../middleware/webhook-queue"; +import { z } from "zod"; + +export const statusRoutes = new Hono(); + +const statusCategoryEnum = z.enum(["todo", "in_progress", "done", "cancelled"]); +const MAX_STATUSES_PER_PROJECT = 15; + +const createStatusSchema = z.object({ + key: z + .string() + .regex(/^[a-z0-9_]+$/, "Key must be lowercase letters, numbers and underscores") + .optional(), + label: z.string().min(1, "Label is required").max(60), + category: statusCategoryEnum.optional().default("todo"), + color: z.string().optional().nullable(), + sortOrder: z.number().int().optional(), + isDefault: z.boolean().optional(), +}); + +const updateStatusSchema = z.object({ + key: z + .string() + .regex(/^[a-z0-9_]+$/, "Key must be lowercase letters, numbers and underscores") + .optional(), + label: z.string().min(1).max(60).optional(), + category: statusCategoryEnum.optional(), + color: z.string().optional().nullable(), + sortOrder: z.number().int().optional(), + isDefault: z.boolean().optional(), +}); + +const reorderStatusSchema = z.object({ + orderedIds: z.array(z.string().uuid("Invalid status id")).min(1, "orderedIds is required"), +}); + +function slugifyKey(label: string): string { + const key = label + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .replace(/_+/g, "_"); + return key || "status"; +} + +async function getProject(c: any, projectId: string) { + const [project] = await db + .select({ id: projects.id, name: projects.name, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + if (!project) { + throw new AuthError("Project not found", 404, "NOT_FOUND"); + } + await requireWorkspaceAccess(c, project.domainId); + return project; +} + +// GET /api/projects/:projectId/statuses — List statuses for a project +statusRoutes.get("/:projectId/statuses", async (c) => { + try { + const user = await requireAuth(c); + const projectId = c.req.param("projectId"); + if (!isUuid(projectId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + const project = await getProject(c, projectId); + + const items = await db.select() + .from(statusDefinitions) + .where(eq(statusDefinitions.projectId, projectId)) + .orderBy(asc(statusDefinitions.sortOrder), asc(statusDefinitions.createdAt)); + + return c.json({ items, totalItems: items.length }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[statuses] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list statuses" } }, 500); + } +}); + +// POST /api/projects/:projectId/statuses — Create a status +statusRoutes.post("/:projectId/statuses", async (c) => { + try { + const user = await requireAuth(c); + const projectId = c.req.param("projectId"); + if (!isUuid(projectId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + const body = await c.req.json(); + const data = createStatusSchema.parse(body); + const project = await getProject(c, projectId); + + // Cap the number of statuses per project + const [countResult] = await db.select({ count: sql`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/timeline.ts b/apps/api/src/routes/timeline.ts new file mode 100644 index 0000000..ce11004 --- /dev/null +++ b/apps/api/src/routes/timeline.ts @@ -0,0 +1,118 @@ +import { Hono } from "hono"; +import { db, projects, sections, statusDefinitions, taskDependencies, tasks } from "@project-e/db"; +import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth"; + +export const timelineRoutes = new Hono(); + +// Columns of the status_definitions table flattened onto a task row as `status` +// (null when the task has no status or its status was deleted). +const statusColumns = { + id: statusDefinitions.id, + projectId: statusDefinitions.projectId, + key: statusDefinitions.key, + label: statusDefinitions.label, + category: statusDefinitions.category, + color: statusDefinitions.color, + sortOrder: statusDefinitions.sortOrder, + isDefault: statusDefinitions.isDefault, +}; + +// GET /api/domains/:domainId/projects/:projectId/timeline — Gantt data for a project: +// task bars (with status + dependencies) and milestone sections. Tasks without a +// startDate field use createdAt as the bar start. +timelineRoutes.get("/domains/:domainId/projects/:projectId/timeline", async (c) => { + try { + const user = await requireAuth(c); + void user; + const domainId = c.req.param("domainId"); + const projectId = c.req.param("projectId"); + if (!isUuid(domainId) || !isUuid(projectId)) { + return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404); + } + + await requireWorkspaceAccess(c, domainId); + + const [project] = await db.select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.domainId, domainId), isNull(projects.deletedAt))) + .limit(1); + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + const [taskRows, milestoneRows] = await Promise.all([ + db.select({ + id: tasks.id, + title: tasks.title, + statusId: tasks.statusId, + sectionId: tasks.sectionId, + dueDate: tasks.dueDate, + createdAt: tasks.createdAt, + status: statusColumns, + }) + .from(tasks) + .leftJoin(statusDefinitions, eq(tasks.statusId, statusDefinitions.id)) + .where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt))) + .orderBy(asc(tasks.createdAt)), + db.select({ + id: sections.id, + name: sections.name, + targetDate: sections.targetDate, + sortOrder: sections.sortOrder, + }) + .from(sections) + .where(and( + eq(sections.projectId, projectId), + eq(sections.kind, "milestone"), + isNotNull(sections.targetDate), + )) + .orderBy(asc(sections.targetDate), asc(sections.sortOrder)), + ]); + + // Dependency map: taskId → ids of tasks it depends on. Only edges between + // tasks in this project are kept so arrows never point outside the chart. + const depsByTask = new Map(); + 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 new file mode 100644 index 0000000..2cb469f --- /dev/null +++ b/apps/web/src/components/automation-rule-builder.tsx @@ -0,0 +1,528 @@ +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 new file mode 100644 index 0000000..9c5ae75 --- /dev/null +++ b/apps/web/src/components/gantt/gantt-chart.tsx @@ -0,0 +1,267 @@ +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 new file mode 100644 index 0000000..a7a2bba --- /dev/null +++ b/apps/web/src/components/gantt/gantt-dependency-arrow.tsx @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..1ef81bf --- /dev/null +++ b/apps/web/src/components/gantt/gantt-milestone.tsx @@ -0,0 +1,24 @@ +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 new file mode 100644 index 0000000..9307d70 --- /dev/null +++ b/apps/web/src/components/gantt/gantt-task-bar.tsx @@ -0,0 +1,105 @@ +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 new file mode 100644 index 0000000..597fa88 --- /dev/null +++ b/apps/web/src/components/gantt/gantt-timeline-header.tsx @@ -0,0 +1,101 @@ +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 new file mode 100644 index 0000000..d21894a --- /dev/null +++ b/apps/web/src/components/gantt/gantt-utils.ts @@ -0,0 +1,82 @@ +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 new file mode 100644 index 0000000..0052e02 --- /dev/null +++ b/apps/web/src/components/notification-center.tsx @@ -0,0 +1,253 @@ +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 new file mode 100644 index 0000000..1608ef4 --- /dev/null +++ b/apps/web/src/components/quick-add-bar.tsx @@ -0,0 +1,234 @@ +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 new file mode 100644 index 0000000..8f59298 --- /dev/null +++ b/apps/web/src/lib/nlp-parser.test.ts @@ -0,0 +1,257 @@ +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 new file mode 100644 index 0000000..c77d0e3 --- /dev/null +++ b/apps/web/src/lib/nlp-parser.ts @@ -0,0 +1,458 @@ +/** + * 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/routes/__root.tsx b/apps/web/src/routes/__root.tsx new file mode 100644 index 0000000..00c36e4 --- /dev/null +++ b/apps/web/src/routes/__root.tsx @@ -0,0 +1,13 @@ +import { createRootRoute, Outlet } from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import { Toaster } from "@/components/ui/sonner"; + +export const Route = createRootRoute({ + component: () => ( +
+ + + {import.meta.env.DEV && } +
+ ), +}); diff --git a/apps/web/src/routes/_app/canvas/$id.tsx b/apps/web/src/routes/_app/canvas/$id.tsx new file mode 100644 index 0000000..210e2d9 --- /dev/null +++ b/apps/web/src/routes/_app/canvas/$id.tsx @@ -0,0 +1,181 @@ +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/worker/src/index.ts b/apps/worker/src/index.ts index 4d2c8fc..d6e9b99 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -316,7 +316,6 @@ async function handleRecurringSpawn(job: typeof jobs.$inferSelect): Promise statement-breakpoint +ALTER TABLE "automation_rules" ADD CONSTRAINT "automation_rules_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "automation_rules_project_id_idx" ON "automation_rules" USING btree ("project_id"); diff --git a/drizzle/0008_plane-lift-schema.sql b/drizzle/0008_plane-lift-schema.sql new file mode 100644 index 0000000..022f641 --- /dev/null +++ b/drizzle/0008_plane-lift-schema.sql @@ -0,0 +1,81 @@ +-- Plane-Lift schema: big-bang break (DB is empty, no data migration) +-- Adds states, modules, cycles, links tables. +-- Drops note_links, note_entity_links, task_dependencies. +-- Drops tasks.status enum column, adds state_id/module_id/cycle_id FKs. +-- Drops the task_status enum type. + +-- ── Drop old junction tables ──────────────────────────────────────────────────── +DROP TABLE IF EXISTS "task_dependencies" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "note_links" CASCADE;--> statement-breakpoint +DROP TABLE IF EXISTS "note_entity_links" CASCADE;--> statement-breakpoint + +-- ── Create new enums ─────────────────────────────────────────────────────────── +CREATE TYPE "state_group" AS ENUM ('backlog', 'unstarted', 'started', 'completed', 'cancelled');--> statement-breakpoint +CREATE TYPE "module_status" AS ENUM ('planned', 'in_progress', 'completed', 'cancelled');--> statement-breakpoint +CREATE TYPE "link_type" AS ENUM ('relates', 'blocks', 'parent-child', 'created-from');--> statement-breakpoint + +-- ── Create new tables ────────────────────────────────────────────────────────── +CREATE TABLE "states" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "color" text, + "group" "state_group" NOT NULL DEFAULT 'unstarted', + "project_id" uuid NOT NULL, + "sort_order" integer DEFAULT 0, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE INDEX "states_project_id_idx" ON "states" ("project_id");--> statement-breakpoint +CREATE INDEX "states_sort_order_idx" ON "states" ("project_id","sort_order");--> statement-breakpoint +CREATE TABLE "modules" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "description" text, + "project_id" uuid NOT NULL, + "status" "module_status" NOT NULL DEFAULT 'planned', + "start_date" timestamp with time zone, + "target_date" timestamp with time zone, + "sort_order" integer DEFAULT 0, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE INDEX "modules_project_id_idx" ON "modules" ("project_id");--> statement-breakpoint +CREATE TABLE "cycles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "project_id" uuid NOT NULL, + "start_date" timestamp with time zone, + "end_date" timestamp with time zone, + "active" boolean DEFAULT false, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE INDEX "cycles_project_id_idx" ON "cycles" ("project_id");--> statement-breakpoint +CREATE TABLE "links" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "source_type" text NOT NULL, + "source_id" uuid NOT NULL, + "target_type" text NOT NULL, + "target_id" uuid NOT NULL, + "link_type" "link_type" NOT NULL, + "direction" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE INDEX "links_source_idx" ON "links" ("source_type","source_id");--> statement-breakpoint +CREATE INDEX "links_target_idx" ON "links" ("target_type","target_id");--> statement-breakpoint + +-- ── Modify tasks table ───────────────────────────────────────────────────────── +ALTER TABLE "tasks" ADD COLUMN "state_id" uuid;--> statement-breakpoint +ALTER TABLE "tasks" ADD COLUMN "module_id" uuid;--> statement-breakpoint +ALTER TABLE "tasks" ADD COLUMN "cycle_id" uuid;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_state_id_states_id_fk" FOREIGN KEY ("state_id") REFERENCES "states"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_module_id_modules_id_fk" FOREIGN KEY ("module_id") REFERENCES "modules"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_cycle_id_cycles_id_fk" FOREIGN KEY ("cycle_id") REFERENCES "cycles"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "tasks_state_id_idx" ON "tasks" ("state_id");--> statement-breakpoint +CREATE INDEX "tasks_module_id_idx" ON "tasks" ("module_id");--> statement-breakpoint +CREATE INDEX "tasks_cycle_id_idx" ON "tasks" ("cycle_id");--> statement-breakpoint +ALTER TABLE "tasks" DROP COLUMN "status";--> statement-breakpoint +DROP INDEX IF EXISTS "tasks_status_idx";--> statement-breakpoint + +-- ── Drop old enum type ───────────────────────────────────────────────────────── +DROP TYPE IF EXISTS "task_status" CASCADE; diff --git a/drizzle/0009_notifications.sql b/drizzle/0009_notifications.sql new file mode 100644 index 0000000..e38b321 --- /dev/null +++ b/drizzle/0009_notifications.sql @@ -0,0 +1,20 @@ +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "workspace_id" uuid, + "type" text NOT NULL, + "title" text NOT NULL, + "body" text, + "entity_type" text, + "entity_id" uuid, + "read_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_workspace_id_domains_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."domains"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "notifications_user_read_idx" ON "notifications" USING btree ("user_id","read_at");--> statement-breakpoint +CREATE INDEX "notifications_workspace_id_idx" ON "notifications" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "notifications_entity_idx" ON "notifications" USING btree ("entity_type","entity_id"); diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..6b46791 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,4641 @@ +{ + "id": "bcd0bbd3-a263-43c5-95be-93d431171fdf", + "prevId": "5f43138d-c7d8-4deb-b039-db806525ef83", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_feed": { + "name": "activity_feed", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_feed_workspace_id_idx": { + "name": "activity_feed_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_feed_entity_idx": { + "name": "activity_feed_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_feed_created_at_idx": { + "name": "activity_feed_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "activity_feed_actor_idx": { + "name": "activity_feed_actor_idx", + "columns": [ + { + "expression": "actor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "activity_feed_workspace_id_domains_id_fk": { + "name": "activity_feed_workspace_id_domains_id_fk", + "tableFrom": "activity_feed", + "columnsFrom": [ + "workspace_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_activity": { + "name": "agent_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_activity_agent_id_idx": { + "name": "agent_activity_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_activity_created_at_idx": { + "name": "agent_activity_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "agent_activity_agent_id_agents_id_fk": { + "name": "agent_activity_agent_id_agents_id_fk", + "tableFrom": "agent_activity", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_tasks": { + "name": "agent_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_type": { + "name": "task_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_tasks_agent_id_idx": { + "name": "agent_tasks_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_tasks_status_idx": { + "name": "agent_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "agent_tasks_agent_id_agents_id_fk": { + "name": "agent_tasks_agent_id_agents_id_fk", + "tableFrom": "agent_tasks", + "columnsFrom": [ + "agent_id" + ], + "tableTo": "agents", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "permission_tier": { + "name": "permission_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'read_only'" + }, + "custom_permissions": { + "name": "custom_permissions", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_domain_id_idx": { + "name": "agents_domain_id_idx", + "columns": [ + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agents_status_idx": { + "name": "agents_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "agents_domain_id_domains_id_fk": { + "name": "agents_domain_id_domains_id_fk", + "tableFrom": "agents", + "columnsFrom": [ + "domain_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "api_keys_active_idx": { + "name": "api_keys_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_events": { + "name": "calendar_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recurrence_rule": { + "name": "recurrence_rule", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calendar_events_domain_id_idx": { + "name": "calendar_events_domain_id_idx", + "columns": [ + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_events_start_time_idx": { + "name": "calendar_events_start_time_idx", + "columns": [ + { + "expression": "start_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "calendar_events_end_time_idx": { + "name": "calendar_events_end_time_idx", + "columns": [ + { + "expression": "end_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "calendar_events_domain_id_domains_id_fk": { + "name": "calendar_events_domain_id_domains_id_fk", + "tableFrom": "calendar_events", + "columnsFrom": [ + "domain_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.canvas_cards": { + "name": "canvas_cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "canvas_id": { + "name": "canvas_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'note'" + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x": { + "name": "x", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "y": { + "name": "y", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 200 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 150 + }, + "rotation": { + "name": "rotation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "z_index": { + "name": "z_index", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "canvas_cards_canvas_id_idx": { + "name": "canvas_cards_canvas_id_idx", + "columns": [ + { + "expression": "canvas_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "canvas_cards_canvas_id_canvases_id_fk": { + "name": "canvas_cards_canvas_id_canvases_id_fk", + "tableFrom": "canvas_cards", + "columnsFrom": [ + "canvas_id" + ], + "tableTo": "canvases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.canvas_connections": { + "name": "canvas_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "canvas_id": { + "name": "canvas_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_card_id": { + "name": "source_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_card_id": { + "name": "target_card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "style": { + "name": "style", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'solid'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "canvas_connections_canvas_id_idx": { + "name": "canvas_connections_canvas_id_idx", + "columns": [ + { + "expression": "canvas_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "canvas_connections_canvas_id_canvases_id_fk": { + "name": "canvas_connections_canvas_id_canvases_id_fk", + "tableFrom": "canvas_connections", + "columnsFrom": [ + "canvas_id" + ], + "tableTo": "canvases", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "canvas_connections_source_card_id_canvas_cards_id_fk": { + "name": "canvas_connections_source_card_id_canvas_cards_id_fk", + "tableFrom": "canvas_connections", + "columnsFrom": [ + "source_card_id" + ], + "tableTo": "canvas_cards", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "canvas_connections_target_card_id_canvas_cards_id_fk": { + "name": "canvas_connections_target_card_id_canvas_cards_id_fk", + "tableFrom": "canvas_connections", + "columnsFrom": [ + "target_card_id" + ], + "tableTo": "canvas_cards", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.canvases": { + "name": "canvases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'freeform'" + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "viewport": { + "name": "viewport", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"x\":0,\"y\":0,\"zoom\":1}'::jsonb" + }, + "background": { + "name": "background", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "canvases_domain_id_idx": { + "name": "canvases_domain_id_idx", + "columns": [ + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "canvases_domain_id_domains_id_fk": { + "name": "canvases_domain_id_domains_id_fk", + "tableFrom": "canvases", + "columnsFrom": [ + "domain_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "comments_workspace_id_idx": { + "name": "comments_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "comments_entity_idx": { + "name": "comments_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "comments_parent_id_idx": { + "name": "comments_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "comments_created_at_idx": { + "name": "comments_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "comments_workspace_id_domains_id_fk": { + "name": "comments_workspace_id_domains_id_fk", + "tableFrom": "comments", + "columnsFrom": [ + "workspace_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "comments_parent_id_comments_id_fk": { + "name": "comments_parent_id_comments_id_fk", + "tableFrom": "comments", + "columnsFrom": [ + "parent_id" + ], + "tableTo": "comments", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_fields": { + "name": "custom_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "default_value": { + "name": "default_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_fields_domain_id_idx": { + "name": "custom_fields_domain_id_idx", + "columns": [ + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "custom_fields_entity_type_idx": { + "name": "custom_fields_entity_type_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "custom_fields_domain_id_domains_id_fk": { + "name": "custom_fields_domain_id_domains_id_fk", + "tableFrom": "custom_fields", + "columnsFrom": [ + "domain_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_notes": { + "name": "daily_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mood": { + "name": "mood", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "energy": { + "name": "energy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "daily_notes_domain_id_idx": { + "name": "daily_notes_domain_id_idx", + "columns": [ + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "daily_notes_date_idx": { + "name": "daily_notes_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "daily_notes_date_domain_idx": { + "name": "daily_notes_date_domain_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "daily_notes_domain_id_domains_id_fk": { + "name": "daily_notes_domain_id_domains_id_fk", + "tableFrom": "daily_notes", + "columnsFrom": [ + "domain_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_widgets": { + "name": "dashboard_widgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"x\":0,\"y\":0,\"w\":2,\"h\":2}'::jsonb" + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dashboard_widgets_user_id_idx": { + "name": "dashboard_widgets_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "dashboard_widgets_domain_id_idx": { + "name": "dashboard_widgets_domain_id_idx", + "columns": [ + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "dashboard_widgets_user_id_users_id_fk": { + "name": "dashboard_widgets_user_id_users_id_fk", + "tableFrom": "dashboard_widgets", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "dashboard_widgets_domain_id_domains_id_fk": { + "name": "dashboard_widgets_domain_id_domains_id_fk", + "tableFrom": "dashboard_widgets", + "columnsFrom": [ + "domain_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domains": { + "name": "domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "domains_parent_id_idx": { + "name": "domains_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "domains_slug_idx": { + "name": "domains_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "domains_search_idx": { + "name": "domains_search_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "domains_owner_id_users_id_fk": { + "name": "domains_owner_id_users_id_fk", + "tableFrom": "domains", + "columnsFrom": [ + "owner_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "domains_parent_id_domains_id_fk": { + "name": "domains_parent_id_domains_id_fk", + "tableFrom": "domains", + "columnsFrom": [ + "parent_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "domains_slug_unique": { + "name": "domains_slug_unique", + "columns": [ + "slug" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_logs": { + "name": "error_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'error'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stack_trace": { + "name": "stack_trace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "resolved": { + "name": "resolved", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "error_logs_level_idx": { + "name": "error_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "error_logs_created_at_idx": { + "name": "error_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "error_logs_resolved_idx": { + "name": "error_logs_resolved_idx", + "columns": [ + { + "expression": "resolved", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.habit_completions": { + "name": "habit_completions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "habit_id": { + "name": "habit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "mood": { + "name": "mood", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "habit_completions_habit_id_idx": { + "name": "habit_completions_habit_id_idx", + "columns": [ + { + "expression": "habit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "habit_completions_date_idx": { + "name": "habit_completions_date_idx", + "columns": [ + { + "expression": "habit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "habit_completions_habit_id_habits_id_fk": { + "name": "habit_completions_habit_id_habits_id_fk", + "tableFrom": "habit_completions", + "columnsFrom": [ + "habit_id" + ], + "tableTo": "habits", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.habit_tags": { + "name": "habit_tags", + "schema": "", + "columns": { + "habit_id": { + "name": "habit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "habit_tags_tag_id_idx": { + "name": "habit_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "habit_tags_habit_id_habits_id_fk": { + "name": "habit_tags_habit_id_habits_id_fk", + "tableFrom": "habit_tags", + "columnsFrom": [ + "habit_id" + ], + "tableTo": "habits", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "habit_tags_tag_id_tags_id_fk": { + "name": "habit_tags_tag_id_tags_id_fk", + "tableFrom": "habit_tags", + "columnsFrom": [ + "tag_id" + ], + "tableTo": "tags", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "habit_tags_habit_id_tag_id_pk": { + "name": "habit_tags_habit_id_tag_id_pk", + "columns": [ + "habit_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.habits": { + "name": "habits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "habit_frequency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'daily'" + }, + "difficulty": { + "name": "difficulty", + "type": "habit_difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "goal_per_period": { + "name": "goal_per_period", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reminder_time": { + "name": "reminder_time", + "type": "time", + "primaryKey": false, + "notNull": false + }, + "skip_days": { + "name": "skip_days", + "type": "integer[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "streak_count": { + "name": "streak_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "best_streak": { + "name": "best_streak", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "mood_tracking": { + "name": "mood_tracking", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "habits_domain_id_idx": { + "name": "habits_domain_id_idx", + "columns": [ + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "habits_active_idx": { + "name": "habits_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "habits_deleted_at_idx": { + "name": "habits_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "habits_search_idx": { + "name": "habits_search_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "habits_domain_id_domains_id_fk": { + "name": "habits_domain_id_domains_id_fk", + "tableFrom": "habits", + "columnsFrom": [ + "domain_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "jobs_status_idx": { + "name": "jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "jobs_type_idx": { + "name": "jobs_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "jobs_next_retry_at_idx": { + "name": "jobs_next_retry_at_idx", + "columns": [ + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_entity_links": { + "name": "note_entity_links", + "schema": "", + "columns": { + "note_id": { + "name": "note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "note_entity_links_entity_idx": { + "name": "note_entity_links_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "note_entity_links_note_id_idx": { + "name": "note_entity_links_note_id_idx", + "columns": [ + { + "expression": "note_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "note_entity_links_note_id_notes_id_fk": { + "name": "note_entity_links_note_id_notes_id_fk", + "tableFrom": "note_entity_links", + "columnsFrom": [ + "note_id" + ], + "tableTo": "notes", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_links": { + "name": "note_links", + "schema": "", + "columns": { + "source_note_id": { + "name": "source_note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_note_id": { + "name": "target_note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "note_links_target_note_id_idx": { + "name": "note_links_target_note_id_idx", + "columns": [ + { + "expression": "target_note_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "note_links_source_note_id_notes_id_fk": { + "name": "note_links_source_note_id_notes_id_fk", + "tableFrom": "note_links", + "columnsFrom": [ + "source_note_id" + ], + "tableTo": "notes", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "note_links_target_note_id_notes_id_fk": { + "name": "note_links_target_note_id_notes_id_fk", + "tableFrom": "note_links", + "columnsFrom": [ + "target_note_id" + ], + "tableTo": "notes", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "note_links_source_note_id_target_note_id_pk": { + "name": "note_links_source_note_id_target_note_id_pk", + "columns": [ + "source_note_id", + "target_note_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.note_tags": { + "name": "note_tags", + "schema": "", + "columns": { + "note_id": { + "name": "note_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "note_tags_tag_id_idx": { + "name": "note_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "note_tags_note_id_notes_id_fk": { + "name": "note_tags_note_id_notes_id_fk", + "tableFrom": "note_tags", + "columnsFrom": [ + "note_id" + ], + "tableTo": "notes", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "note_tags_tag_id_tags_id_fk": { + "name": "note_tags_tag_id_tags_id_fk", + "tableFrom": "note_tags", + "columnsFrom": [ + "tag_id" + ], + "tableTo": "tags", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "note_tags_note_id_tag_id_pk": { + "name": "note_tags_note_id_tag_id_pk", + "columns": [ + "note_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "notes_domain_id_idx": { + "name": "notes_domain_id_idx", + "columns": [ + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "notes_is_pinned_idx": { + "name": "notes_is_pinned_idx", + "columns": [ + { + "expression": "is_pinned", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "notes_is_archived_idx": { + "name": "notes_is_archived_idx", + "columns": [ + { + "expression": "is_archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "notes_deleted_at_idx": { + "name": "notes_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "notes_search_idx": { + "name": "notes_search_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "notes_domain_id_domains_id_fk": { + "name": "notes_domain_id_domains_id_fk", + "tableFrom": "notes", + "columnsFrom": [ + "domain_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_tags": { + "name": "project_tags", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "project_tags_tag_id_idx": { + "name": "project_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "project_tags_project_id_projects_id_fk": { + "name": "project_tags_project_id_projects_id_fk", + "tableFrom": "project_tags", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "project_tags_tag_id_tags_id_fk": { + "name": "project_tags_tag_id_tags_id_fk", + "tableFrom": "project_tags", + "columnsFrom": [ + "tag_id" + ], + "tableTo": "tags", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "project_tags_project_id_tag_id_pk": { + "name": "project_tags_project_id_tag_id_pk", + "columns": [ + "project_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_domain_id_idx": { + "name": "projects_domain_id_idx", + "columns": [ + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "projects_status_idx": { + "name": "projects_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "projects_deleted_at_idx": { + "name": "projects_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "projects_search_idx": { + "name": "projects_search_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "projects_domain_id_domains_id_fk": { + "name": "projects_domain_id_domains_id_fk", + "tableFrom": "projects", + "columnsFrom": [ + "domain_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Untitled report'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_type": { + "name": "report_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "date_range_start": { + "name": "date_range_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "date_range_end": { + "name": "date_range_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_domain_idx": { + "name": "reports_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "reports_type_idx": { + "name": "reports_type_idx", + "columns": [ + { + "expression": "report_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "reports_created_at_idx": { + "name": "reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "reports_project_id_projects_id_fk": { + "name": "reports_project_id_projects_id_fk", + "tableFrom": "reports", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_jobs": { + "name": "scheduled_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recurrence_rule": { + "name": "recurrence_rule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "next_occurrence_at": { + "name": "next_occurrence_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_spawned_at": { + "name": "last_spawned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scheduled_jobs_next_occurrence_idx": { + "name": "scheduled_jobs_next_occurrence_idx", + "columns": [ + { + "expression": "next_occurrence_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "scheduled_jobs_entity_idx": { + "name": "scheduled_jobs_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sections": { + "name": "sections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "section_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'section'" + }, + "status": { + "name": "status", + "type": "section_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "target_date": { + "name": "target_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sections_project_id_idx": { + "name": "sections_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sections_kind_idx": { + "name": "sections_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "sections_sort_order_idx": { + "name": "sections_sort_order_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "sections_project_id_projects_id_fk": { + "name": "sections_project_id_projects_id_fk", + "tableFrom": "sections", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "tag_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tags_parent_id_idx": { + "name": "tags_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tags_scope_idx": { + "name": "tags_scope_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "tags_parent_id_tags_id_fk": { + "name": "tags_parent_id_tags_id_fk", + "tableFrom": "tags", + "columnsFrom": [ + "parent_id" + ], + "tableTo": "tags", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_dependencies": { + "name": "task_dependencies", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "depends_on_task_id": { + "name": "depends_on_task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "task_dependencies_depends_on_idx": { + "name": "task_dependencies_depends_on_idx", + "columns": [ + { + "expression": "depends_on_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "task_dependencies_task_id_tasks_id_fk": { + "name": "task_dependencies_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "task_dependencies_depends_on_task_id_tasks_id_fk": { + "name": "task_dependencies_depends_on_task_id_tasks_id_fk", + "tableFrom": "task_dependencies", + "columnsFrom": [ + "depends_on_task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "task_dependencies_task_id_depends_on_task_id_pk": { + "name": "task_dependencies_task_id_depends_on_task_id_pk", + "columns": [ + "task_id", + "depends_on_task_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_tags": { + "name": "task_tags", + "schema": "", + "columns": { + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "task_tags_tag_id_idx": { + "name": "task_tags_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "task_tags_task_id_tasks_id_fk": { + "name": "task_tags_task_id_tasks_id_fk", + "tableFrom": "task_tags", + "columnsFrom": [ + "task_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "task_tags_tag_id_tags_id_fk": { + "name": "task_tags_tag_id_tags_id_fk", + "tableFrom": "task_tags", + "columnsFrom": [ + "tag_id" + ], + "tableTo": "tags", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "task_tags_task_id_tag_id_pk": { + "name": "task_tags_task_id_tag_id_pk", + "columns": [ + "task_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'todo'" + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "domain_id": { + "name": "domain_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "section_id": { + "name": "section_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "estimated_minutes": { + "name": "estimated_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tracked_minutes": { + "name": "tracked_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "recurrence_rule": { + "name": "recurrence_rule", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "custom_fields": { + "name": "custom_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tasks_domain_id_idx": { + "name": "tasks_domain_id_idx", + "columns": [ + { + "expression": "domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_section_id_idx": { + "name": "tasks_section_id_idx", + "columns": [ + { + "expression": "section_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_parent_id_idx": { + "name": "tasks_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_priority_idx": { + "name": "tasks_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_due_date_idx": { + "name": "tasks_due_date_idx", + "columns": [ + { + "expression": "due_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_order_idx": { + "name": "tasks_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_deleted_at_idx": { + "name": "tasks_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "tasks_search_idx": { + "name": "tasks_search_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "tasks_domain_id_domains_id_fk": { + "name": "tasks_domain_id_domains_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "domain_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "project_id" + ], + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "tasks_section_id_sections_id_fk": { + "name": "tasks_section_id_sections_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "section_id" + ], + "tableTo": "sections", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + }, + "tasks_parent_id_tasks_id_fk": { + "name": "tasks_parent_id_tasks_id_fk", + "tableFrom": "tasks", + "columnsFrom": [ + "parent_id" + ], + "tableTo": "tasks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passkey_credential_id": { + "name": "passkey_credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "passkey_public_key": { + "name": "passkey_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "passkey_counter": { + "name": "passkey_counter", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_deliveries_webhook_id_idx": { + "name": "webhook_deliveries_webhook_id_idx", + "columns": [ + { + "expression": "webhook_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "webhook_deliveries_status_idx": { + "name": "webhook_deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "webhook_deliveries_created_at_idx": { + "name": "webhook_deliveries_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "columnsFrom": [ + "webhook_id" + ], + "tableTo": "webhooks", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhooks_workspace_id_idx": { + "name": "webhooks_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "webhooks_active_idx": { + "name": "webhooks_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "webhooks_workspace_id_domains_id_fk": { + "name": "webhooks_workspace_id_domains_id_fk", + "tableFrom": "webhooks", + "columnsFrom": [ + "workspace_id" + ], + "tableTo": "domains", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.habit_difficulty": { + "name": "habit_difficulty", + "schema": "public", + "values": [ + "easy", + "medium", + "hard" + ] + }, + "public.habit_frequency": { + "name": "habit_frequency", + "schema": "public", + "values": [ + "daily", + "weekly", + "custom" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "failed" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "active", + "paused", + "completed", + "archived" + ] + }, + "public.section_kind": { + "name": "section_kind", + "schema": "public", + "values": [ + "section", + "milestone" + ] + }, + "public.section_status": { + "name": "section_status", + "schema": "public", + "values": [ + "planned", + "in_progress", + "complete" + ] + }, + "public.tag_scope": { + "name": "tag_scope", + "schema": "public", + "values": [ + "global", + "tasks", + "habits", + "projects", + "notes" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "low", + "medium", + "high", + "urgent" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "todo", + "in_progress", + "done", + "cancelled" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..6c79381 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,83 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "dialect": "postgresql", + "tag": "0000_outstanding_zuras", + "createdAt": "2025-01-01T00:00:00.000Z" + }, + { + "idx": 1, + "version": "7", + "dialect": "postgresql", + "tag": "0001_warm_eternity", + "createdAt": "2025-01-01T00:01:00.000Z" + }, + { + "idx": 2, + "version": "7", + "dialect": "postgresql", + "tag": "0002_steep_black_widow", + "createdAt": "2025-01-01T00:02:00.000Z" + }, + { + "idx": 3, + "version": "7", + "dialect": "postgresql", + "tag": "0003_amazing_saracen", + "createdAt": "2025-01-01T00:03:00.000Z" + }, + { + "idx": 4, + "version": "7", + "dialect": "postgresql", + "tag": "0004_add_owner_id_to_domains", + "createdAt": "2025-01-01T00:04:00.000Z" + }, + { + "idx": 5, + "version": "7", + "dialect": "postgresql", + "tag": "0005_search_vector_trigger", + "createdAt": "2025-01-01T00:05:00.000Z" + }, + { + "idx": 6, + "version": "7", + "dialect": "postgresql", + "tag": "0006_minor_doctor_octopus", + "createdAt": "2025-01-01T00:06:00.000Z" + }, + { + "idx": 7, + "version": "7", + "dialect": "postgresql", + "tag": "0007_drop_canvas_reports", + "createdAt": "2025-01-01T00:07:00.000Z" + }, + { + "idx": 8, + "version": "7", + "when": 1788800393149, + "tag": "0008_plane-lift-schema", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1786392464857, + "tag": "0006_minor_doctor_octopus", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1786392464858, + "tag": "0007_drop_canvas_reports", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 7b8e363..00048f6 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -23,7 +23,6 @@ export const tsvector = customType<{ data: string }>({ // ── Enums ────────────────────────────────────────────────────────────────────── -export const taskStatusEnum = pgEnum('task_status', ['todo', 'in_progress', 'done', 'cancelled']); export const taskPriorityEnum = pgEnum('task_priority', ['low', 'medium', 'high', 'urgent']); export const habitFrequencyEnum = pgEnum('habit_frequency', ['daily', 'weekly', 'custom']); export const habitDifficultyEnum = pgEnum('habit_difficulty', ['easy', 'medium', 'hard']); @@ -32,6 +31,9 @@ export const sectionKindEnum = pgEnum('section_kind', ['section', 'milestone']); export const sectionStatusEnum = pgEnum('section_status', ['planned', 'in_progress', 'complete']); export const tagScopeEnum = pgEnum('tag_scope', ['global', 'tasks', 'habits', 'projects', 'notes']); export const jobStatusEnum = pgEnum('job_status', ['pending', 'processing', 'completed', 'failed']); +export const stateGroupEnum = pgEnum('state_group', ['backlog', 'unstarted', 'started', 'completed', 'cancelled']); +export const moduleStatusEnum = pgEnum('module_status', ['planned', 'in_progress', 'completed', 'cancelled']); +export const linkTypeEnum = pgEnum('link_type', ['relates', 'blocks', 'parent-child', 'created-from']); // ── Users ────────────────────────────────────────────────────────────────────── @@ -143,6 +145,72 @@ export const sections = pgTable( ] ); +// ── States (per-project workflow states) ──────────────────────────────────────── + +export const states = pgTable( + 'states', + { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + color: text('color'), + group: stateGroupEnum('group').notNull().default('unstarted'), + projectId: uuid('project_id') + .notNull() + .references((): any => projects.id, { onDelete: 'cascade' }), + sortOrder: integer('sort_order').default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('states_project_id_idx').on(table.projectId), + index('states_sort_order_idx').on(table.projectId, table.sortOrder), + ] +); + +// ── Modules (project-scoped planning buckets) ────────────────────────────────── + +export const modules = pgTable( + 'modules', + { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + description: text('description'), + projectId: uuid('project_id') + .notNull() + .references((): any => projects.id, { onDelete: 'cascade' }), + status: moduleStatusEnum('status').notNull().default('planned'), + startDate: timestamp('start_date', { withTimezone: true }), + targetDate: timestamp('target_date', { withTimezone: true }), + sortOrder: integer('sort_order').default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('modules_project_id_idx').on(table.projectId), + ] +); + +// ── Cycles (time-boxed sprints) ──────────────────────────────────────────────── + +export const cycles = pgTable( + 'cycles', + { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + projectId: uuid('project_id') + .notNull() + .references((): any => projects.id, { onDelete: 'cascade' }), + startDate: timestamp('start_date', { withTimezone: true }), + endDate: timestamp('end_date', { withTimezone: true }), + active: boolean('active').default(false), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('cycles_project_id_idx').on(table.projectId), + ] +); + // ── Tasks ─────────────────────────────────────────────────────────────────────── export const tasks = pgTable( @@ -151,13 +219,15 @@ export const tasks = pgTable( id: uuid('id').defaultRandom().primaryKey(), title: text('title').notNull(), description: text('description'), - status: taskStatusEnum('status').notNull().default('todo'), priority: taskPriorityEnum('priority').notNull().default('medium'), domainId: uuid('domain_id') .notNull() .references((): any => domains.id, { onDelete: 'cascade' }), projectId: uuid('project_id').references((): any => projects.id, { onDelete: 'set null' }), sectionId: uuid('section_id').references((): any => sections.id, { onDelete: 'set null' }), + stateId: uuid('state_id').references((): any => states.id, { onDelete: 'set null' }), + moduleId: uuid('module_id').references((): any => modules.id, { onDelete: 'set null' }), + cycleId: uuid('cycle_id').references((): any => cycles.id, { onDelete: 'set null' }), parentId: uuid('parent_id').references((): any => tasks.id, { onDelete: 'set null' }), dueDate: timestamp('due_date', { withTimezone: true }), completedAt: timestamp('completed_at', { withTimezone: true }), @@ -175,8 +245,10 @@ export const tasks = pgTable( index('tasks_domain_id_idx').on(table.domainId), index('tasks_project_id_idx').on(table.projectId), index('tasks_section_id_idx').on(table.sectionId), + index('tasks_state_id_idx').on(table.stateId), + index('tasks_module_id_idx').on(table.moduleId), + index('tasks_cycle_id_idx').on(table.cycleId), index('tasks_parent_id_idx').on(table.parentId), - index('tasks_status_idx').on(table.status), index('tasks_priority_idx').on(table.priority), index('tasks_due_date_idx').on(table.dueDate), index('tasks_order_idx').on(table.order), @@ -203,24 +275,6 @@ export const taskTags = pgTable( ] ); -// ── Task Dependencies (junction) ─────────────────────────────────────────────── - -export const taskDependencies = pgTable( - 'task_dependencies', - { - taskId: uuid('task_id') - .notNull() - .references((): any => tasks.id, { onDelete: 'cascade' }), - dependsOnTaskId: uuid('depends_on_task_id') - .notNull() - .references((): any => tasks.id, { onDelete: 'cascade' }), - }, - (table) => [ - primaryKey({ columns: [table.taskId, table.dependsOnTaskId] }), - index('task_dependencies_depends_on_idx').on(table.dependsOnTaskId), - ] -); - // ── Habits ────────────────────────────────────────────────────────────────────── export const habits = pgTable( @@ -321,41 +375,6 @@ export const notes = pgTable( ] ); -// ── Note Links (wikilinks / backlinks) ───────────────────────────────────────── - -export const noteLinks = pgTable( - 'note_links', - { - sourceNoteId: uuid('source_note_id') - .notNull() - .references((): any => notes.id, { onDelete: 'cascade' }), - targetNoteId: uuid('target_note_id') - .notNull() - .references((): any => notes.id, { onDelete: 'cascade' }), - }, - (table) => [ - primaryKey({ columns: [table.sourceNoteId, table.targetNoteId] }), - index('note_links_target_note_id_idx').on(table.targetNoteId), - ] -); - -// ── Note Entity Links (cross-entity linking) ──────────────────────────────────── - -export const noteEntityLinks = pgTable( - 'note_entity_links', - { - noteId: uuid('note_id') - .notNull() - .references((): any => notes.id, { onDelete: 'cascade' }), - entityType: text('entity_type').notNull(), - entityId: uuid('entity_id').notNull(), - }, - (table) => [ - index('note_entity_links_entity_idx').on(table.entityType, table.entityId), - index('note_entity_links_note_id_idx').on(table.noteId), - ] -); - // ── Note Tags (junction) ──────────────────────────────────────────────────────── export const noteTags = pgTable( @@ -374,6 +393,26 @@ export const noteTags = pgTable( ] ); +// ── Links (canonical cross-entity mesh) ──────────────────────────────────────── + +export const links = pgTable( + 'links', + { + id: uuid('id').defaultRandom().primaryKey(), + sourceType: text('source_type').notNull(), + sourceId: uuid('source_id').notNull(), + targetType: text('target_type').notNull(), + targetId: uuid('target_id').notNull(), + linkType: linkTypeEnum('link_type').notNull(), + direction: text('direction'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index('links_source_idx').on(table.sourceType, table.sourceId), + index('links_target_idx').on(table.targetType, table.targetId), + ] +); + // ── Project Tags (junction) ───────────────────────────────────────────────────── export const projectTags = pgTable( diff --git a/script/migrate-task-statuses.ts b/script/migrate-task-statuses.ts new file mode 100644 index 0000000..c865b56 --- /dev/null +++ b/script/migrate-task-statuses.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from "node:url"; +import { sql } from "../packages/db/src/client"; + +// Apply the custom-workflow-statuses migration idempotently. +// 0007_custom_task_statuses.sql uses CREATE ... IF NOT EXISTS, pg_constraint / +// information_schema guards, and idempotent backfill UPDATEs, so it is safe to +// run on every deploy. It must run BEFORE `drizzle-kit push`: the push removes +// the legacy tasks.status column and task_status enum type, and this script +// backfills tasks.status_id from that column first. +const statusesFile = fileURLToPath( + new URL("../drizzle/0007_custom_task_statuses.sql", import.meta.url), +); + +try { + console.log(`Applying custom task statuses migration from ${statusesFile} ...`); + await sql.file(statusesFile); + console.log("Custom task statuses migration applied successfully."); + process.exit(0); +} catch (error) { + console.error("Failed to apply custom task statuses migration:", error); + process.exit(1); +} \ No newline at end of file