From b6e2ab415ec6ef1c6fbb04faacf5a135f2d3c296 Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Wed, 29 Jul 2026 06:12:35 -0400 Subject: [PATCH] feat: Phase 2 - Tasks CRUD API, kanban/list UI, dialogs, activity feed, keyboard shortcuts - Tasks REST API under /api/domains/[domainId]/tasks/ with full CRUD, filtering, pagination - Complete/uncomplete endpoints - Bulk update endpoint for drag-to-reorder - Dependencies API with cycle detection - Tags API for task tagging - Activity feed API scoped to workspace - Updated kanban board view with 4 columns (todo/in_progress/done/cancelled) - Updated list view with status column and workspace-scoped API calls - Task create dialog with title, description, status, priority, due date, estimate - Task detail panel (sheet) with full edit capabilities - Task activity feed widget - Keyboard shortcuts: c t (new task), e (edit), d (delete), Space (open), Esc (close), 1-4 (filter) - All routes follow AGENTS.md contract: Drizzle writes + activity feed + pg_notify --- apps/web/app/(dashboard)/tasks/page.tsx | 80 +++- .../api/domains/[domainId]/activity/route.ts | 47 ++ .../[domainId]/tasks/[id]/complete/route.ts | 47 ++ .../tasks/[id]/dependencies/route.ts | 162 +++++++ .../domains/[domainId]/tasks/[id]/route.ts | 203 +++++++++ .../[domainId]/tasks/[id]/tags/route.ts | 123 ++++++ .../[domainId]/tasks/[id]/uncomplete/route.ts | 47 ++ .../domains/[domainId]/tasks/bulk/route.ts | 79 ++++ .../app/api/domains/[domainId]/tasks/route.ts | 218 ++++++++++ .../components/tasks/task-activity-feed.tsx | 94 ++++ .../components/tasks/task-create-dialog.tsx | 203 +++++++++ .../components/tasks/task-detail-panel.tsx | 373 ++++++++++------ .../components/tasks/tasks-kanban-view.tsx | 405 ++++++++---------- apps/web/components/tasks/tasks-list-view.tsx | 148 ++++--- apps/web/hooks/use-keyboard-shortcuts.ts | 57 +++ apps/web/tsconfig.tsbuildinfo | 2 +- 16 files changed, 1880 insertions(+), 408 deletions(-) create mode 100644 apps/web/app/api/domains/[domainId]/activity/route.ts create mode 100644 apps/web/app/api/domains/[domainId]/tasks/[id]/complete/route.ts create mode 100644 apps/web/app/api/domains/[domainId]/tasks/[id]/dependencies/route.ts create mode 100644 apps/web/app/api/domains/[domainId]/tasks/[id]/route.ts create mode 100644 apps/web/app/api/domains/[domainId]/tasks/[id]/tags/route.ts create mode 100644 apps/web/app/api/domains/[domainId]/tasks/[id]/uncomplete/route.ts create mode 100644 apps/web/app/api/domains/[domainId]/tasks/bulk/route.ts create mode 100644 apps/web/app/api/domains/[domainId]/tasks/route.ts create mode 100644 apps/web/components/tasks/task-activity-feed.tsx create mode 100644 apps/web/components/tasks/task-create-dialog.tsx diff --git a/apps/web/app/(dashboard)/tasks/page.tsx b/apps/web/app/(dashboard)/tasks/page.tsx index 018e7ba..bd981d6 100644 --- a/apps/web/app/(dashboard)/tasks/page.tsx +++ b/apps/web/app/(dashboard)/tasks/page.tsx @@ -1,19 +1,53 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { LayoutGrid, List, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TasksKanbanView } from "@/components/tasks/tasks-kanban-view"; import { TasksListView } from "@/components/tasks/tasks-list-view"; -import { CreateItemDialog } from "@/components/create-item-dialog"; +import { TaskCreateDialog } from "@/components/tasks/task-create-dialog"; import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store"; export default function TasksPage() { const [view, setView] = useState<"kanban" | "list">("kanban"); const [refreshKey, setRefreshKey] = useState(0); + const [domainId, setDomainId] = useState(null); + const [domains, setDomains] = useState<{ id: string; name: string; color: string | null }[]>([]); + const [createOpen, setCreateOpen] = useState(false); + const [createStatus, setCreateStatus] = useState('todo'); const { open, openCreate, closeCreate } = useCreateDialogStore(); + // Fetch domains and select first one + useEffect(() => { + fetch('/api/domains?sort=sort_order') + .then((res) => res.json()) + .then((data) => { + const items = data.items || []; + setDomains(items); + if (items.length > 0 && !domainId) { + setDomainId(items[0].id); + } + }) + .catch(() => {}); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Listen for custom event to open create dialog with pre-filled status + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.status) setCreateStatus(detail.status); + if (detail?.domainId) setDomainId(detail.domainId); + setCreateOpen(true); + }; + document.addEventListener('open-create-task', handler); + return () => document.removeEventListener('open-create-task', handler); + }, []); + + const handleRefresh = useCallback(() => { + setRefreshKey((k) => k + 1); + }, []); + return (
@@ -22,7 +56,20 @@ export default function TasksPage() {

Move work forward without losing the thread.

- @@ -34,8 +81,31 @@ export default function TasksPage() {
- {view === "kanban" ? : } - (o ? openCreate("task") : closeCreate())} onCreated={() => setRefreshKey((k) => k + 1)} /> + {domainId ? ( + view === "kanban" ? ( + + ) : ( + + ) + ) : ( +
+

No domains found. Create one in Settings first.

+
+ )} + + {/* Create dialog */} + + + {/* Legacy create dialog for backward compat */} +
+
openCreate('task')} /> +
); } diff --git a/apps/web/app/api/domains/[domainId]/activity/route.ts b/apps/web/app/api/domains/[domainId]/activity/route.ts new file mode 100644 index 0000000..e5b1de5 --- /dev/null +++ b/apps/web/app/api/domains/[domainId]/activity/route.ts @@ -0,0 +1,47 @@ +// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST: +// 1. Insert activity feed entry +// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id })) +// See AGENTS.md for full rules. + +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth'; +import { db, activityFeed } from '@project-e/db'; +import { and, desc, eq, sql } from 'drizzle-orm'; + +type RouteContext = { params: Promise<{ domainId: string }> }; + +// GET /api/domains/[domainId]/activity — List activity feed for a workspace +export const GET = withAuth(async (request: NextRequest, user, context) => { + const { domainId } = await context!.params; + await requireWorkspaceAccess(domainId); + + const { searchParams } = new URL(request.url); + const entityType = searchParams.get('entity_type'); + const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100); + const offset = parseInt(searchParams.get('offset') || '0'); + + const conditions: any[] = [eq(activityFeed.workspaceId, domainId)]; + + if (entityType) { + conditions.push(eq(activityFeed.entityType, entityType)); + } + + const [items, countResult] = await Promise.all([ + db.select() + .from(activityFeed) + .where(and(...conditions)) + .orderBy(desc(activityFeed.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: sql`count(*)` }) + .from(activityFeed) + .where(and(...conditions)), + ]); + + return NextResponse.json({ + items, + totalItems: Number(countResult[0]?.count || 0), + limit, + offset, + }); +}); diff --git a/apps/web/app/api/domains/[domainId]/tasks/[id]/complete/route.ts b/apps/web/app/api/domains/[domainId]/tasks/[id]/complete/route.ts new file mode 100644 index 0000000..a963459 --- /dev/null +++ b/apps/web/app/api/domains/[domainId]/tasks/[id]/complete/route.ts @@ -0,0 +1,47 @@ +// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST: +// 1. Insert activity feed entry +// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id })) +// See AGENTS.md for full rules. + +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth'; +import { recordActivity } from '@/lib/activity'; +import { db, tasks } from '@project-e/db'; +import { and, eq, isNull } from 'drizzle-orm'; + +type RouteContext = { params: Promise<{ domainId: string; id: string }> }; + +// POST /api/domains/[domainId]/tasks/[id]/complete — Mark task as done +export const POST = withAuth(async (request: NextRequest, user, context) => { + const { domainId, id } = await context!.params; + await requireWorkspaceAccess(domainId); + + const [existing] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))) + .limit(1); + + if (!existing) { + return createErrorResponse('NOT_FOUND', 'Task not found', 404); + } + + const [updated] = await db.update(tasks) + .set({ + status: 'done', + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(tasks.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: 'completed', + entityType: 'task', + entityId: id, + changes: { previousStatus: existing.status }, + workspaceId: domainId, + }); + + return NextResponse.json(updated); +}); diff --git a/apps/web/app/api/domains/[domainId]/tasks/[id]/dependencies/route.ts b/apps/web/app/api/domains/[domainId]/tasks/[id]/dependencies/route.ts new file mode 100644 index 0000000..fa35ffa --- /dev/null +++ b/apps/web/app/api/domains/[domainId]/tasks/[id]/dependencies/route.ts @@ -0,0 +1,162 @@ +// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST: +// 1. Insert activity feed entry +// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id })) +// See AGENTS.md for full rules. + +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth'; +import { recordActivity } from '@/lib/activity'; +import { db, tasks, taskDependencies } from '@project-e/db'; +import { and, eq, isNull } from 'drizzle-orm'; +import { z } from 'zod'; + +const addDependencySchema = z.object({ + taskId: z.string().uuid(), +}); + +type RouteContext = { params: Promise<{ domainId: string; id: string }> }; + +/** + * Cycle detection: check if adding dep (taskId -> dependsOnTaskId) would create a cycle. + * Uses BFS from dependsOnTaskId following the dependency chain. + */ +async function wouldCreateCycle(taskId: string, dependsOnTaskId: string): Promise { + if (taskId === dependsOnTaskId) return true; + + // BFS: follow dependencies from dependsOnTaskId to see if we reach taskId + const visited = new Set(); + const queue = [dependsOnTaskId]; + + while (queue.length > 0) { + const current = queue.shift()!; + if (current === taskId) return true; + if (visited.has(current)) continue; + visited.add(current); + + const deps = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId }) + .from(taskDependencies) + .where(eq(taskDependencies.taskId, current)); + + for (const dep of deps) { + if (!visited.has(dep.dependsOnTaskId)) { + queue.push(dep.dependsOnTaskId); + } + } + } + + return false; +} + +// POST /api/domains/[domainId]/tasks/[id]/dependencies — Add a dependency +export const POST = withAuth(async (request: NextRequest, user, context) => { + const { domainId, id } = await context!.params; + await requireWorkspaceAccess(domainId); + + try { + const body = await request.json(); + const data = addDependencySchema.parse(body); + + // Verify both tasks exist + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return createErrorResponse('NOT_FOUND', 'Task not found', 404); + } + + const [depTask] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, data.taskId), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))) + .limit(1); + + if (!depTask) { + return createErrorResponse('NOT_FOUND', 'Dependency task not found', 404); + } + + // Cycle detection + const cycle = await wouldCreateCycle(id, data.taskId); + if (cycle) { + return createErrorResponse('CONFLICT', 'Adding this dependency would create a cycle', 400); + } + + // Check if dependency already exists + const [existing] = await db.select() + .from(taskDependencies) + .where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, data.taskId))) + .limit(1); + + if (existing) { + return createErrorResponse('CONFLICT', 'Dependency already exists', 409); + } + + await db.insert(taskDependencies).values({ + taskId: id, + dependsOnTaskId: data.taskId, + }); + + await recordActivity({ + actor: user.name, + action: 'dependency_added', + entityType: 'task', + entityId: id, + changes: { dependsOnTaskId: data.taskId, dependsOnTitle: depTask.title }, + workspaceId: domainId, + }); + + return NextResponse.json({ success: true }, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + if (error instanceof ApiError) { + return createErrorResponse(error.code, error.message, error.status); + } + console.error('[dependencies POST] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to add dependency', 500); + } +}); + +// DELETE /api/domains/[domainId]/tasks/[id]/dependencies — Remove a dependency +export const DELETE = withAuth(async (request: NextRequest, user, context) => { + const { domainId, id } = await context!.params; + await requireWorkspaceAccess(domainId); + + try { + const body = await request.json(); + const data = addDependencySchema.parse(body); + + const [existing] = await db.select() + .from(taskDependencies) + .where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, data.taskId))) + .limit(1); + + if (!existing) { + return createErrorResponse('NOT_FOUND', 'Dependency not found', 404); + } + + await db.delete(taskDependencies) + .where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, data.taskId))); + + await recordActivity({ + actor: user.name, + action: 'dependency_removed', + entityType: 'task', + entityId: id, + changes: { dependsOnTaskId: data.taskId }, + workspaceId: domainId, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + if (error instanceof ApiError) { + return createErrorResponse(error.code, error.message, error.status); + } + console.error('[dependencies DELETE] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to remove dependency', 500); + } +}); diff --git a/apps/web/app/api/domains/[domainId]/tasks/[id]/route.ts b/apps/web/app/api/domains/[domainId]/tasks/[id]/route.ts new file mode 100644 index 0000000..a42c9f2 --- /dev/null +++ b/apps/web/app/api/domains/[domainId]/tasks/[id]/route.ts @@ -0,0 +1,203 @@ +// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST: +// 1. Insert activity feed entry +// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id })) +// See AGENTS.md for full rules. + +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth'; +import { recordActivity } from '@/lib/activity'; +import { db, tasks, taskTags, tags as tagsTable, taskDependencies } from '@project-e/db'; +import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm'; +import { z } from 'zod'; + +const taskStatusEnum = z.enum(['todo', 'in_progress', 'done', 'cancelled']); +const taskPriorityEnum = z.enum(['low', 'medium', 'high', 'urgent']); + +const updateTaskSchema = z.object({ + title: z.string().min(1).optional(), + description: z.string().optional().nullable(), + status: taskStatusEnum.optional(), + priority: taskPriorityEnum.optional(), + projectId: z.string().uuid().optional().nullable(), + sectionId: z.string().uuid().optional().nullable(), + parentId: z.string().uuid().optional().nullable(), + dueDate: z.string().datetime().optional().nullable(), + estimatedMinutes: z.number().int().positive().optional().nullable(), + order: z.number().int().optional(), + customFields: z.record(z.string(), z.unknown()).optional(), +}); + +type RouteContext = { params: Promise<{ domainId: string; id: string }> }; + +// GET /api/domains/[domainId]/tasks/[id] — Get a single task with subtasks + dependencies +export const GET = withAuth(async (request: NextRequest, user, context) => { + const { domainId, id } = await context!.params; + await requireWorkspaceAccess(domainId); + + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return createErrorResponse('NOT_FOUND', 'Task not found', 404); + } + + // Fetch subtasks + const subtasks = await db.select() + .from(tasks) + .where(and(eq(tasks.parentId, id), isNull(tasks.deletedAt))) + .orderBy(asc(tasks.order)); + + // Fetch tags + const tagRows = await db.select({ + id: tagsTable.id, + name: tagsTable.name, + color: tagsTable.color, + }) + .from(taskTags) + .innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id)) + .where(eq(taskTags.taskId, id)); + + // Fetch dependencies (tasks this task depends on) + const depRows = await db.select({ + id: tasks.id, + title: tasks.title, + status: tasks.status, + }) + .from(taskDependencies) + .innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id)) + .where(and(eq(taskDependencies.taskId, id), isNull(tasks.deletedAt))); + + // Fetch dependents (tasks that depend on this task) + const dependentRows = await db.select({ + id: tasks.id, + title: tasks.title, + status: tasks.status, + }) + .from(taskDependencies) + .innerJoin(tasks, eq(taskDependencies.taskId, tasks.id)) + .where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt))); + + return NextResponse.json({ + ...task, + subtasks, + tags: tagRows, + dependencies: depRows, + dependents: dependentRows, + }); +}); + +// PATCH /api/domains/[domainId]/tasks/[id] — Update a task +export const PATCH = withAuth(async (request: NextRequest, user, context) => { + const { domainId, id } = await context!.params; + await requireWorkspaceAccess(domainId); + + try { + const body = await request.json(); + const data = updateTaskSchema.parse(body); + + // Verify task exists + const [existing] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))) + .limit(1); + + if (!existing) { + return createErrorResponse('NOT_FOUND', 'Task not found', 404); + } + + // Cycle detection for parentId (can't set parent to self or descendant) + if (data.parentId && data.parentId === id) { + return createErrorResponse('VALIDATION_ERROR', 'A task cannot be its own parent', 400); + } + if (data.parentId) { + // Check for cycles in parent chain + let currentParentId: string | null = data.parentId; + const visited = new Set([id]); + while (currentParentId) { + if (visited.has(currentParentId)) { + return createErrorResponse('VALIDATION_ERROR', 'Circular parent reference detected', 400); + } + visited.add(currentParentId); + const [parent] = await db.select({ parentId: tasks.parentId }) + .from(tasks) + .where(eq(tasks.id, currentParentId)) + .limit(1); + currentParentId = parent?.parentId ?? null; + } + } + + // Build update object + const updateValues: Record = {}; + if (data.title !== undefined) updateValues.title = data.title; + if (data.description !== undefined) updateValues.description = data.description; + if (data.status !== undefined) updateValues.status = data.status; + if (data.priority !== undefined) updateValues.priority = data.priority; + if (data.projectId !== undefined) updateValues.projectId = data.projectId; + if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId; + if (data.parentId !== undefined) updateValues.parentId = data.parentId; + if (data.dueDate !== undefined) updateValues.dueDate = data.dueDate ? new Date(data.dueDate) : null; + if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes; + if (data.order !== undefined) updateValues.order = data.order; + if (data.customFields !== undefined) updateValues.customFields = data.customFields; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(tasks) + .set(updateValues) + .where(eq(tasks.id, id)) + .returning(); + + // Record activity + await recordActivity({ + actor: user.name, + action: 'updated', + entityType: 'task', + entityId: id, + changes: { ...data, previousStatus: existing.status }, + workspaceId: domainId, + }); + + return NextResponse.json(updated); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + if (error instanceof ApiError) { + return createErrorResponse(error.code, error.message, error.status); + } + console.error('[tasks PATCH] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to update task', 500); + } +}); + +// DELETE /api/domains/[domainId]/tasks/[id] — Soft delete a task +export const DELETE = withAuth(async (request: NextRequest, user, context) => { + const { domainId, id } = await context!.params; + await requireWorkspaceAccess(domainId); + + const [existing] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))) + .limit(1); + + if (!existing) { + return createErrorResponse('NOT_FOUND', 'Task not found', 404); + } + + await db.update(tasks) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(tasks.id, id)); + + // Record activity + await recordActivity({ + actor: user.name, + action: 'deleted', + entityType: 'task', + entityId: id, + changes: { title: existing.title }, + workspaceId: domainId, + }); + + return new NextResponse(null, { status: 204 }); +}); diff --git a/apps/web/app/api/domains/[domainId]/tasks/[id]/tags/route.ts b/apps/web/app/api/domains/[domainId]/tasks/[id]/tags/route.ts new file mode 100644 index 0000000..2cea87e --- /dev/null +++ b/apps/web/app/api/domains/[domainId]/tasks/[id]/tags/route.ts @@ -0,0 +1,123 @@ +// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST: +// 1. Insert activity feed entry +// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id })) +// See AGENTS.md for full rules. + +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth'; +import { recordActivity } from '@/lib/activity'; +import { db, tasks, taskTags, tags as tagsTable } from '@project-e/db'; +import { and, eq, isNull } from 'drizzle-orm'; +import { z } from 'zod'; + +const tagActionSchema = z.object({ + tagId: z.string().uuid(), +}); + +type RouteContext = { params: Promise<{ domainId: string; id: string }> }; + +// POST /api/domains/[domainId]/tasks/[id]/tags — Add a tag to a task +export const POST = withAuth(async (request: NextRequest, user, context) => { + const { domainId, id } = await context!.params; + await requireWorkspaceAccess(domainId); + + try { + const body = await request.json(); + const data = tagActionSchema.parse(body); + + // Verify task exists + const [task] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return createErrorResponse('NOT_FOUND', 'Task not found', 404); + } + + // Verify tag exists + const [tag] = await db.select() + .from(tagsTable) + .where(eq(tagsTable.id, data.tagId)) + .limit(1); + + if (!tag) { + return createErrorResponse('NOT_FOUND', 'Tag not found', 404); + } + + // Check if already tagged + const [existing] = await db.select() + .from(taskTags) + .where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, data.tagId))) + .limit(1); + + if (existing) { + return createErrorResponse('CONFLICT', 'Tag already added to this task', 409); + } + + await db.insert(taskTags).values({ taskId: id, tagId: data.tagId }); + + await recordActivity({ + actor: user.name, + action: 'tag_added', + entityType: 'task', + entityId: id, + changes: { tagId: data.tagId, tagName: tag.name }, + workspaceId: domainId, + }); + + return NextResponse.json({ success: true }, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + if (error instanceof ApiError) { + return createErrorResponse(error.code, error.message, error.status); + } + console.error('[tags POST] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to add tag', 500); + } +}); + +// DELETE /api/domains/[domainId]/tasks/[id]/tags — Remove a tag from a task +export const DELETE = withAuth(async (request: NextRequest, user, context) => { + const { domainId, id } = await context!.params; + await requireWorkspaceAccess(domainId); + + try { + const body = await request.json(); + const data = tagActionSchema.parse(body); + + const [existing] = await db.select() + .from(taskTags) + .where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, data.tagId))) + .limit(1); + + if (!existing) { + return createErrorResponse('NOT_FOUND', 'Tag not found on this task', 404); + } + + await db.delete(taskTags) + .where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, data.tagId))); + + await recordActivity({ + actor: user.name, + action: 'tag_removed', + entityType: 'task', + entityId: id, + changes: { tagId: data.tagId }, + workspaceId: domainId, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + if (error instanceof ApiError) { + return createErrorResponse(error.code, error.message, error.status); + } + console.error('[tags DELETE] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to remove tag', 500); + } +}); diff --git a/apps/web/app/api/domains/[domainId]/tasks/[id]/uncomplete/route.ts b/apps/web/app/api/domains/[domainId]/tasks/[id]/uncomplete/route.ts new file mode 100644 index 0000000..81801d3 --- /dev/null +++ b/apps/web/app/api/domains/[domainId]/tasks/[id]/uncomplete/route.ts @@ -0,0 +1,47 @@ +// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST: +// 1. Insert activity feed entry +// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id })) +// See AGENTS.md for full rules. + +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth'; +import { recordActivity } from '@/lib/activity'; +import { db, tasks } from '@project-e/db'; +import { and, eq, isNull } from 'drizzle-orm'; + +type RouteContext = { params: Promise<{ domainId: string; id: string }> }; + +// POST /api/domains/[domainId]/tasks/[id]/uncomplete — Revert task from done +export const POST = withAuth(async (request: NextRequest, user, context) => { + const { domainId, id } = await context!.params; + await requireWorkspaceAccess(domainId); + + const [existing] = await db.select() + .from(tasks) + .where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))) + .limit(1); + + if (!existing) { + return createErrorResponse('NOT_FOUND', 'Task not found', 404); + } + + const [updated] = await db.update(tasks) + .set({ + status: 'todo', + completedAt: null, + updatedAt: new Date(), + }) + .where(eq(tasks.id, id)) + .returning(); + + await recordActivity({ + actor: user.name, + action: 'uncompleted', + entityType: 'task', + entityId: id, + changes: { previousStatus: existing.status }, + workspaceId: domainId, + }); + + return NextResponse.json(updated); +}); diff --git a/apps/web/app/api/domains/[domainId]/tasks/bulk/route.ts b/apps/web/app/api/domains/[domainId]/tasks/bulk/route.ts new file mode 100644 index 0000000..2f40214 --- /dev/null +++ b/apps/web/app/api/domains/[domainId]/tasks/bulk/route.ts @@ -0,0 +1,79 @@ +// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST: +// 1. Insert activity feed entry +// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id })) +// See AGENTS.md for full rules. + +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth'; +import { recordActivity } from '@/lib/activity'; +import { db, tasks } from '@project-e/db'; +import { and, eq, inArray, isNull } from 'drizzle-orm'; +import { z } from 'zod'; + +const bulkUpdateSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(200), + updates: z.object({ + status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(), + priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(), + order: z.number().int().optional(), + projectId: z.string().uuid().optional().nullable(), + sectionId: z.string().uuid().optional().nullable(), + }), +}); + +type RouteContext = { params: Promise<{ domainId: string }> }; + +// POST /api/domains/[domainId]/tasks/bulk — Bulk update tasks (order, status) +export const POST = withAuth(async (request: NextRequest, user, context) => { + const { domainId } = await context!.params; + await requireWorkspaceAccess(domainId); + + try { + const body = await request.json(); + const data = bulkUpdateSchema.parse(body); + + // Verify all tasks belong to this domain + const existingTasks = await db.select({ id: tasks.id }) + .from(tasks) + .where(and(inArray(tasks.id, data.ids), eq(tasks.domainId, domainId), isNull(tasks.deletedAt))); + + if (existingTasks.length !== data.ids.length) { + return createErrorResponse('NOT_FOUND', 'One or more tasks not found', 404); + } + + const updateValues: Record = { updatedAt: new Date() }; + if (data.updates.status !== undefined) updateValues.status = data.updates.status; + if (data.updates.priority !== undefined) updateValues.priority = data.updates.priority; + if (data.updates.order !== undefined) updateValues.order = data.updates.order; + if (data.updates.projectId !== undefined) updateValues.projectId = data.updates.projectId; + if (data.updates.sectionId !== undefined) updateValues.sectionId = data.updates.sectionId; + + const updated = await db.update(tasks) + .set(updateValues) + .where(inArray(tasks.id, data.ids)) + .returning(); + + // Record activity for each task + for (const task of updated) { + await recordActivity({ + actor: user.name, + action: 'bulk_updated', + entityType: 'task', + entityId: task.id, + changes: data.updates, + workspaceId: domainId, + }); + } + + return NextResponse.json({ updated: updated.length, items: updated }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + if (error instanceof ApiError) { + return createErrorResponse(error.code, error.message, error.status); + } + console.error('[tasks bulk POST] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to bulk update tasks', 500); + } +}); diff --git a/apps/web/app/api/domains/[domainId]/tasks/route.ts b/apps/web/app/api/domains/[domainId]/tasks/route.ts new file mode 100644 index 0000000..138d204 --- /dev/null +++ b/apps/web/app/api/domains/[domainId]/tasks/route.ts @@ -0,0 +1,218 @@ +// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST: +// 1. Insert activity feed entry +// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id })) +// See AGENTS.md for full rules. + +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth'; +import { recordActivity } from '@/lib/activity'; +import { db, tasks, taskTags, tags as tagsTable, taskDependencies, domains } from '@project-e/db'; +import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; +import { z } from 'zod'; + +const taskStatusEnum = z.enum(['todo', 'in_progress', 'done', 'cancelled']); +const taskPriorityEnum = z.enum(['low', 'medium', 'high', 'urgent']); + +const createTaskSchema = z.object({ + title: z.string().min(1, 'Title is required'), + description: z.string().optional().nullable(), + status: taskStatusEnum.optional().default('todo'), + priority: taskPriorityEnum.optional().default('medium'), + projectId: z.string().uuid().optional().nullable(), + sectionId: z.string().uuid().optional().nullable(), + parentId: z.string().uuid().optional().nullable(), + dueDate: z.string().datetime().optional().nullable(), + estimatedMinutes: z.number().int().positive().optional().nullable(), + order: z.number().int().optional(), + customFields: z.record(z.string(), z.unknown()).optional(), + tagIds: z.array(z.string().uuid()).optional(), +}); + +type RouteContext = { params: Promise<{ domainId: string }> }; + +// GET /api/domains/[domainId]/tasks — List tasks with filtering, sorting, pagination +export const GET = withAuth(async (request: NextRequest, user, context) => { + const { domainId } = await context!.params; + await requireWorkspaceAccess(domainId); + + const { searchParams } = new URL(request.url); + const status = searchParams.get('status'); + const priority = searchParams.get('priority'); + const tag = searchParams.get('tag'); + const search = searchParams.get('search'); + const parentId = searchParams.get('parent_id'); + const projectId = searchParams.get('project_id'); + const sectionId = searchParams.get('section_id'); + const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200); + const offset = parseInt(searchParams.get('offset') || '0'); + const sort = searchParams.get('sort') || 'order'; + const order = searchParams.get('order') || 'asc'; + + // Build where conditions + const conditions: any[] = [ + eq(tasks.domainId, domainId), + isNull(tasks.deletedAt), + ]; + + if (status) { + const statuses = status.split(','); + conditions.push(inArray(tasks.status, statuses as any)); + } + if (priority) { + const priorities = priority.split(','); + conditions.push(inArray(tasks.priority, priorities as any)); + } + if (search) { + conditions.push(ilike(tasks.title, `%${search}%`)); + } + if (parentId === 'null') { + conditions.push(isNull(tasks.parentId)); + } else if (parentId) { + conditions.push(eq(tasks.parentId, parentId)); + } + if (projectId) { + conditions.push(eq(tasks.projectId, projectId)); + } + if (sectionId) { + conditions.push(eq(tasks.sectionId, sectionId)); + } + + // Build order + const orderFn = order === 'desc' ? desc : asc; + let orderColumn; + switch (sort) { + case 'title': orderColumn = orderFn(tasks.title); break; + case 'status': orderColumn = orderFn(tasks.status); break; + case 'priority': orderColumn = orderFn(tasks.priority); break; + case 'due_date': orderColumn = orderFn(tasks.dueDate); break; + case 'created_at': orderColumn = orderFn(tasks.createdAt); break; + case 'updated_at': orderColumn = orderFn(tasks.updatedAt); break; + default: orderColumn = orderFn(tasks.order); break; + } + + const [items, countResult] = await Promise.all([ + db.select() + .from(tasks) + .where(and(...conditions)) + .orderBy(orderColumn) + .limit(limit) + .offset(offset), + db.select({ count: sql`count(*)` }) + .from(tasks) + .where(and(...conditions)), + ]); + + const totalItems = Number(countResult[0]?.count || 0); + + // If tag filter is specified, filter in-memory (or we could do a subquery) + let filteredItems = items; + if (tag) { + const tagIds = tag.split(','); + const taskTagRows = await db.select({ taskId: taskTags.taskId }) + .from(taskTags) + .where(inArray(taskTags.tagId, tagIds)); + const matchingTaskIds = new Set(taskTagRows.map(r => r.taskId)); + filteredItems = items.filter(t => matchingTaskIds.has(t.id)); + } + + // Fetch tags for all tasks + let taskTagMap = new Map(); + if (filteredItems.length > 0) { + const taskIds = filteredItems.map(t => t.id); + const tagRows = await db.select({ + taskId: taskTags.taskId, + id: tagsTable.id, + name: tagsTable.name, + color: tagsTable.color, + }) + .from(taskTags) + .innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id)) + .where(inArray(taskTags.taskId, taskIds)); + + for (const row of tagRows) { + if (!taskTagMap.has(row.taskId)) taskTagMap.set(row.taskId, []); + taskTagMap.get(row.taskId)!.push({ id: row.id, name: row.name, color: row.color }); + } + } + + const itemsWithTags = filteredItems.map(t => ({ + ...t, + tags: taskTagMap.get(t.id) || [], + })); + + return NextResponse.json({ + items: itemsWithTags, + totalItems, + limit, + offset, + }); +}); + +// POST /api/domains/[domainId]/tasks — Create a task +export const POST = withAuth(async (request: NextRequest, user, context) => { + const { domainId } = await context!.params; + await requireWorkspaceAccess(domainId); + + try { + const body = await request.json(); + const data = createTaskSchema.parse(body); + + // Validate domain_id matches route param + // domainId is already validated via requireWorkspaceAccess + + // Cycle detection for parentId (subtask) + if (data.parentId) { + // Verify parent exists and is not deleted + const [parent] = await db.select({ id: tasks.id }) + .from(tasks) + .where(and(eq(tasks.id, data.parentId), isNull(tasks.deletedAt))) + .limit(1); + if (!parent) { + return createErrorResponse('NOT_FOUND', 'Parent task not found', 404); + } + } + + const [task] = await db.insert(tasks).values({ + title: data.title, + description: data.description ?? null, + status: data.status, + priority: data.priority, + domainId, + projectId: data.projectId ?? null, + sectionId: data.sectionId ?? null, + parentId: data.parentId ?? null, + dueDate: data.dueDate ? new Date(data.dueDate) : null, + estimatedMinutes: data.estimatedMinutes ?? null, + order: data.order ?? 0, + customFields: data.customFields ?? {}, + }).returning(); + + // Insert tags if provided + if (data.tagIds && data.tagIds.length > 0) { + await db.insert(taskTags).values( + data.tagIds.map(tagId => ({ taskId: task.id, tagId })) + ); + } + + // Record activity + await recordActivity({ + actor: user.name, + action: 'created', + entityType: 'task', + entityId: task.id, + changes: { title: task.title, status: task.status, priority: task.priority }, + workspaceId: domainId, + }); + + return NextResponse.json(task, { status: 201 }); + } catch (error) { + if (error instanceof z.ZodError) { + return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues); + } + if (error instanceof ApiError) { + return createErrorResponse(error.code, error.message, error.status); + } + console.error('[tasks POST] error:', error); + return createErrorResponse('INTERNAL_ERROR', 'Failed to create task', 500); + } +}); diff --git a/apps/web/components/tasks/task-activity-feed.tsx b/apps/web/components/tasks/task-activity-feed.tsx new file mode 100644 index 0000000..fd4979a --- /dev/null +++ b/apps/web/components/tasks/task-activity-feed.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Loader2 } from 'lucide-react'; + +interface ActivityEntry { + id: string; + actor: string; + action: string; + entityType: string; + entityId: string; + changes?: Record | null; + createdAt: string; +} + +const actionLabels: Record = { + created: 'created', + updated: 'updated', + deleted: 'deleted', + completed: 'completed', + uncompleted: 'reverted', + bulk_updated: 'bulk updated', + dependency_added: 'added dependency to', + dependency_removed: 'removed dependency from', + tag_added: 'added tag to', + tag_removed: 'removed tag from', +}; + +export function TaskActivityFeed({ domainId }: { domainId: string }) { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!domainId) return; + fetch(`/api/domains/${domainId}/activity?entity_type=task&limit=10`) + .then((res) => { + if (!res.ok) throw new Error('Failed to load activity'); + return res.json(); + }) + .then((data) => { + setActivities(data.items || []); + }) + .catch((err) => { + console.error('Failed to load activity feed:', err); + }) + .finally(() => setLoading(false)); + }, [domainId]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (activities.length === 0) { + return ( +
+

No recent activity

+
+ ); + } + + return ( + +
+ {activities.map((entry) => ( +
+ + {actionLabels[entry.action] || entry.action} + +
+

+ {entry.actor} + {' '} + {actionLabels[entry.action] || entry.action} + {' '} + {entry.changes && typeof entry.changes === 'object' && 'title' in entry.changes + ? `"${entry.changes.title}"` + : entry.entityId.slice(0, 8)} +

+

+ {new Date(entry.createdAt).toLocaleString()} +

+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/components/tasks/task-create-dialog.tsx b/apps/web/components/tasks/task-create-dialog.tsx new file mode 100644 index 0000000..434e5c9 --- /dev/null +++ b/apps/web/components/tasks/task-create-dialog.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { useState, useEffect } from 'react'; +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 { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { toast } from 'sonner'; + +interface TaskCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + domainId: string; + defaultStatus?: 'todo' | 'in_progress' | 'done' | 'cancelled'; + onCreated: () => void; +} + +export function TaskCreateDialog({ + open, + onOpenChange, + domainId, + defaultStatus = 'todo', + onCreated, +}: TaskCreateDialogProps) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [status, setStatus] = useState(defaultStatus); + const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium'); + const [dueDate, setDueDate] = useState(''); + const [estimatedMinutes, setEstimatedMinutes] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + // Reset form when dialog opens + useEffect(() => { + if (open) { + setTitle(''); + setDescription(''); + setStatus(defaultStatus); + setPriority('medium'); + setDueDate(''); + setEstimatedMinutes(''); + setError(''); + } + }, [open, defaultStatus]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!domainId) { + setError('No domain selected'); + return; + } + setSubmitting(true); + setError(''); + + const body: Record = { + title, + status, + priority, + }; + if (description) body.description = description; + if (dueDate) body.dueDate = new Date(dueDate).toISOString(); + if (estimatedMinutes) body.estimatedMinutes = parseInt(estimatedMinutes, 10); + + try { + const response = await fetch(`/api/domains/${domainId}/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error?.message || 'Unable to create task'); + } + + toast.success('Task created'); + onOpenChange(false); + onCreated(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to create task'); + } finally { + setSubmitting(false); + } + } + + return ( + + + + New Task + Create a new task to track your work. + +
+
+ + setTitle(e.target.value)} + placeholder="What needs to be done?" + autoFocus + required + /> +
+ +
+ +