import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { describeRoute, resolver, validator } from "hono-openapi"; import * as v from "valibot"; import db from "../database"; import { assetTable, projectTable, taskTable, userTable, workspaceTable, } from "../database/schema"; import { publishEvent } from "../events"; import { taskSchema } from "../schemas"; import { assertObjectExists, assertTaskImageKeyMatchesContext, createTaskImageUploadUrl, isImageContentType, validateTaskAssetUploadInput, } from "../storage/s3"; import { workspaceAccess } from "../utils/workspace-access-middleware"; import createTask from "./controllers/create-task"; import deleteTask from "./controllers/delete-task"; import exportTasks from "./controllers/export-tasks"; import getTask from "./controllers/get-task"; import getTasks from "./controllers/get-tasks"; import importTasks from "./controllers/import-tasks"; import updateTask from "./controllers/update-task"; import updateTaskAssignee from "./controllers/update-task-assignee"; import updateTaskDescription from "./controllers/update-task-description"; import updateTaskDueDate from "./controllers/update-task-due-date"; import updateTaskPriority from "./controllers/update-task-priority"; import updateTaskStatus from "./controllers/update-task-status"; import updateTaskTitle from "./controllers/update-task-title"; const task = new Hono<{ Variables: { userId: string; }; }>() .get( "/tasks/:projectId", describeRoute({ operationId: "listTasks", tags: ["Tasks"], description: "Get all tasks for a specific project", responses: { 200: { description: "Project with tasks organized by columns", content: { "application/json": { schema: resolver(v.any()) }, }, }, }, }), validator("param", v.object({ projectId: v.string() })), validator( "query", v.optional( v.object({ status: v.optional(v.string()), priority: v.optional(v.string()), assigneeId: v.optional(v.string()), page: v.optional(v.pipe(v.string(), v.transform(Number))), limit: v.optional(v.pipe(v.string(), v.transform(Number))), }), ), ), workspaceAccess.fromProject("projectId"), async (c) => { const { projectId } = c.req.valid("param"); const filters = c.req.valid("query") || {}; const tasks = await getTasks(projectId, filters); return c.json(tasks); }, ) .post( "/:projectId", describeRoute({ operationId: "createTask", tags: ["Tasks"], description: "Create a new task in a project", responses: { 200: { description: "Task created successfully", content: { "application/json": { schema: resolver(taskSchema) }, }, }, }, }), validator( "json", v.object({ title: v.string(), description: v.string(), dueDate: v.optional(v.string()), priority: v.string(), status: v.string(), userId: v.optional(v.string()), }), ), workspaceAccess.fromProject("projectId"), async (c) => { const { projectId } = c.req.param(); const { title, description, dueDate, priority, status, userId } = c.req.valid("json"); const task = await createTask({ projectId, userId, title, description, dueDate: dueDate ? new Date(dueDate) : undefined, priority, status, }); return c.json(task); }, ) .get( "/:id", describeRoute({ operationId: "getTask", tags: ["Tasks"], description: "Get a specific task by ID", responses: { 200: { description: "Task details", content: { "application/json": { schema: resolver(taskSchema) }, }, }, }, }), validator("param", v.object({ id: v.string() })), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const task = await getTask(id); return c.json(task); }, ) .put( "/:id", describeRoute({ operationId: "updateTask", tags: ["Tasks"], description: "Update all fields of a task", responses: { 200: { description: "Task updated successfully", content: { "application/json": { schema: resolver(taskSchema) }, }, }, }, }), validator("param", v.object({ id: v.string() })), validator( "json", v.object({ title: v.string(), description: v.string(), dueDate: v.optional(v.string()), priority: v.string(), status: v.string(), projectId: v.string(), position: v.number(), userId: v.optional(v.string()), }), ), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const existingTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); const { title, description, dueDate, priority, status, projectId, position, userId, } = c.req.valid("json"); if (!existingTask) { throw new HTTPException(404, { message: "Task not found" }); } const task = await updateTask( id, title, status, dueDate ? new Date(dueDate) : undefined, projectId, description, priority, position, userId, ); if (existingTask.status !== status) { const user = c.get("userId"); await publishEvent("task.status_changed", { taskId: task.id, projectId: task.projectId, userId: user, oldStatus: existingTask.status, newStatus: status, title: task.title, assigneeId: task.userId, type: "status_changed", }); } return c.json(task); }, ) .get( "/export/:projectId", describeRoute({ operationId: "exportTasks", tags: ["Tasks"], description: "Export all tasks from a project", responses: { 200: { description: "Exported tasks data", content: { "application/json": { schema: resolver(v.any()) }, }, }, }, }), validator("param", v.object({ projectId: v.string() })), workspaceAccess.fromProject("projectId"), async (c) => { const { projectId } = c.req.valid("param"); const exportData = await exportTasks(projectId); return c.json(exportData); }, ) .post( "/import/:projectId", describeRoute({ operationId: "importTasks", tags: ["Tasks"], description: "Import multiple tasks into a project", responses: { 200: { description: "Tasks imported successfully", content: { "application/json": { schema: resolver(v.any()) }, }, }, }, }), validator("param", v.object({ projectId: v.string() })), validator( "json", v.object({ tasks: v.array( v.object({ title: v.string(), description: v.optional(v.string()), status: v.string(), priority: v.optional(v.string()), dueDate: v.optional(v.string()), userId: v.optional(v.nullable(v.string())), }), ), }), ), workspaceAccess.fromProject("projectId"), async (c) => { const { projectId } = c.req.valid("param"); const { tasks } = c.req.valid("json"); const result = await importTasks(projectId, tasks); return c.json(result); }, ) .delete( "/:id", describeRoute({ operationId: "deleteTask", tags: ["Tasks"], description: "Delete a task by ID", responses: { 200: { description: "Task deleted successfully", content: { "application/json": { schema: resolver(taskSchema) }, }, }, }, }), validator("param", v.object({ id: v.string() })), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const task = await deleteTask(id); return c.json(task); }, ) .put( "/status/:id", describeRoute({ operationId: "updateTaskStatus", tags: ["Tasks"], description: "Update only the status of a task", responses: { 200: { description: "Task status updated successfully", content: { "application/json": { schema: resolver(taskSchema) }, }, }, }, }), validator("param", v.object({ id: v.string() })), validator("json", v.object({ status: v.string() })), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const { status } = c.req.valid("json"); const user = c.get("userId"); const existingTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); if (!existingTask) { throw new HTTPException(404, { message: "Task not found" }); } const task = await updateTaskStatus({ id, status }); await publishEvent("task.status_changed", { taskId: task.id, projectId: task.projectId, userId: user, oldStatus: existingTask.status, newStatus: status, title: task.title, assigneeId: task.userId, type: "status_changed", }); return c.json(task); }, ) .put( "/priority/:id", describeRoute({ operationId: "updateTaskPriority", tags: ["Tasks"], description: "Update only the priority of a task", responses: { 200: { description: "Task priority updated successfully", content: { "application/json": { schema: resolver(taskSchema) }, }, }, }, }), validator("param", v.object({ id: v.string() })), validator("json", v.object({ priority: v.string() })), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const { priority } = c.req.valid("json"); const user = c.get("userId"); const existingTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); if (!existingTask) { throw new HTTPException(404, { message: "Task not found" }); } const task = await updateTaskPriority({ id, priority }); await publishEvent("task.priority_changed", { taskId: task.id, projectId: task.projectId, userId: user, oldPriority: existingTask.priority, newPriority: priority, title: task.title, type: "priority_changed", }); return c.json(task); }, ) .put( "/assignee/:id", describeRoute({ operationId: "updateTaskAssignee", tags: ["Tasks"], description: "Assign or unassign a task to a user", responses: { 200: { description: "Task assignee updated successfully", content: { "application/json": { schema: resolver(taskSchema) }, }, }, }, }), validator("param", v.object({ id: v.string() })), validator("json", v.object({ userId: v.string() })), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const { userId } = c.req.valid("json"); const user = c.get("userId"); const existingTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); if (!existingTask) { throw new HTTPException(404, { message: "Task not found" }); } const task = await updateTaskAssignee({ id, userId }); const newAssigneeName = userId ? ( await db .select({ name: userTable.name }) .from(userTable) .where(eq(userTable.id, userId)) .limit(1) )[0]?.name : undefined; if (!userId) { await publishEvent("task.unassigned", { taskId: task.id, userId: user, title: task.title, type: "unassigned", }); return c.json(task); } await publishEvent("task.assignee_changed", { taskId: task.id, userId: user, oldAssignee: existingTask.userId, newAssignee: newAssigneeName, newAssigneeId: userId, title: task.title, type: "assignee_changed", }); return c.json(task); }, ) .put( "/due-date/:id", describeRoute({ operationId: "updateTaskDueDate", tags: ["Tasks"], description: "Update only the due date of a task", responses: { 200: { description: "Task due date updated successfully", content: { "application/json": { schema: resolver(taskSchema) }, }, }, }, }), validator("param", v.object({ id: v.string() })), validator("json", v.object({ dueDate: v.optional(v.string()) })), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const { dueDate = null } = c.req.valid("json"); const user = c.get("userId"); const existingTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); if (!existingTask) { throw new HTTPException(404, { message: "Task not found" }); } const task = await updateTaskDueDate({ id, dueDate: dueDate ? new Date(dueDate) : null, }); await publishEvent("task.due_date_changed", { taskId: task.id, userId: user, oldDueDate: existingTask.dueDate, newDueDate: dueDate, title: task.title, type: "due_date_changed", }); return c.json(task); }, ) .put( "/title/:id", describeRoute({ operationId: "updateTaskTitle", tags: ["Tasks"], description: "Update only the title of a task", responses: { 200: { description: "Task title updated successfully", content: { "application/json": { schema: resolver(taskSchema) }, }, }, }, }), validator("param", v.object({ id: v.string() })), validator("json", v.object({ title: v.string() })), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const { title } = c.req.valid("json"); const user = c.get("userId"); // Fetch task BEFORE update to get old title const existingTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); if (!existingTask) { throw new HTTPException(404, { message: "Task not found" }); } const task = await updateTaskTitle({ id, title }); await publishEvent("task.title_changed", { taskId: task.id, projectId: task.projectId, userId: user, oldTitle: existingTask.title, newTitle: title, type: "title_changed", }); return c.json(task); }, ) .put( "/image-upload/:id", describeRoute({ operationId: "createTaskImageUpload", tags: ["Tasks"], description: "Create a presigned image upload URL for a task description or comment", responses: { 200: { description: "Image upload URL created successfully", content: { "application/json": { schema: resolver(v.any()) }, }, }, }, }), validator("param", v.object({ id: v.string() })), validator( "json", v.object({ filename: v.string(), contentType: v.string(), size: v.number(), surface: v.picklist(["description", "comment"] as const), }), ), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const { filename, contentType, size, surface } = c.req.valid("json"); try { validateTaskAssetUploadInput(contentType, size); } catch (error) { throw new HTTPException(400, { message: error instanceof Error ? error.message : "Invalid image upload request", }); } const [taskContext] = await db .select({ taskId: taskTable.id, projectId: taskTable.projectId, workspaceId: workspaceTable.id, }) .from(taskTable) .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) .innerJoin( workspaceTable, eq(projectTable.workspaceId, workspaceTable.id), ) .where(eq(taskTable.id, id)) .limit(1); if (!taskContext) { throw new HTTPException(404, { message: "Task not found" }); } try { const upload = await createTaskImageUploadUrl({ workspaceId: taskContext.workspaceId, projectId: taskContext.projectId, taskId: taskContext.taskId, surface, filename, contentType, }); return c.json(upload); } catch (error) { throw new HTTPException(503, { message: error instanceof Error ? error.message : "Image uploads are not configured", }); } }, ) .post( "/image-upload/:id/finalize", describeRoute({ operationId: "finalizeTaskImageUpload", tags: ["Tasks"], description: "Finalize an uploaded task image and create a private asset record", responses: { 200: { description: "Image upload finalized successfully", content: { "application/json": { schema: resolver(v.any()) }, }, }, }, }), validator("param", v.object({ id: v.string() })), validator( "json", v.object({ key: v.string(), filename: v.string(), contentType: v.string(), size: v.number(), surface: v.picklist(["description", "comment"] as const), }), ), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const { key, filename, contentType, size, surface } = c.req.valid("json"); const userId = c.get("userId"); try { validateTaskAssetUploadInput(contentType, size); } catch (error) { throw new HTTPException(400, { message: error instanceof Error ? error.message : "Invalid image upload request", }); } const [taskContext] = await db .select({ taskId: taskTable.id, projectId: taskTable.projectId, workspaceId: workspaceTable.id, }) .from(taskTable) .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) .innerJoin( workspaceTable, eq(projectTable.workspaceId, workspaceTable.id), ) .where(eq(taskTable.id, id)) .limit(1); if (!taskContext) { throw new HTTPException(404, { message: "Task not found" }); } if ( !assertTaskImageKeyMatchesContext(key, { workspaceId: taskContext.workspaceId, projectId: taskContext.projectId, taskId: taskContext.taskId, surface, }) ) { throw new HTTPException(400, { message: "Image upload key does not match the task context.", }); } try { await assertObjectExists(key); } catch { throw new HTTPException(404, { message: "Uploaded object could not be found in storage.", }); } const [existingAsset] = await db .select({ id: assetTable.id }) .from(assetTable) .where(eq(assetTable.objectKey, key)) .limit(1); const [asset] = existingAsset ? await db .update(assetTable) .set({ workspaceId: taskContext.workspaceId, projectId: taskContext.projectId, taskId: taskContext.taskId, filename, mimeType: contentType, size, kind: isImageContentType(contentType) ? "image" : "attachment", surface, createdBy: userId || null, }) .where(eq(assetTable.id, existingAsset.id)) .returning({ id: assetTable.id, }) : await db .insert(assetTable) .values({ workspaceId: taskContext.workspaceId, projectId: taskContext.projectId, taskId: taskContext.taskId, objectKey: key, filename, mimeType: contentType, size, kind: isImageContentType(contentType) ? "image" : "attachment", surface, createdBy: userId || null, }) .returning({ id: assetTable.id, }); return c.json({ id: asset.id, url: new URL(`/api/asset/${asset.id}`, c.req.url).toString(), }); }, ) .put( "/description/:id", describeRoute({ operationId: "updateTaskDescription", tags: ["Tasks"], description: "Update only the description of a task", responses: { 200: { description: "Task description updated successfully", content: { "application/json": { schema: resolver(taskSchema) }, }, }, }, }), validator("param", v.object({ id: v.string() })), validator("json", v.object({ description: v.string() })), workspaceAccess.fromTask(), async (c) => { const { id } = c.req.valid("param"); const { description } = c.req.valid("json"); const user = c.get("userId"); // Fetch task BEFORE update to get old description const existingTask = await db.query.taskTable.findFirst({ where: eq(taskTable.id, id), }); if (!existingTask) { throw new HTTPException(404, { message: "Task not found" }); } const task = await updateTaskDescription({ id, description }); await publishEvent("task.description_changed", { taskId: task.id, projectId: task.projectId, userId: user, oldDescription: existingTask.description, newDescription: description, type: "description_changed", }); return c.json(task); }, ); export default task;