kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { eq } from "drizzle-orm";
2import { Hono } from "hono";
3import { HTTPException } from "hono/http-exception";
4import { describeRoute, resolver, validator } from "hono-openapi";
5import * as v from "valibot";
6import db from "../database";
7import {
8 assetTable,
9 projectTable,
10 taskTable,
11 userTable,
12 workspaceTable,
13} from "../database/schema";
14import { publishEvent } from "../events";
15import { taskSchema } from "../schemas";
16import {
17 assertTaskImageKeyMatchesContext,
18 createTaskImageUploadUrl,
19 isImageContentType,
20 validateTaskAssetUploadInput,
21} from "../storage/s3";
22import { workspaceAccess } from "../utils/workspace-access-middleware";
23import bulkUpdateTasks from "./controllers/bulk-update-tasks";
24import createTask from "./controllers/create-task";
25import deleteTask from "./controllers/delete-task";
26import exportTasks from "./controllers/export-tasks";
27import getTask from "./controllers/get-task";
28import getTasks from "./controllers/get-tasks";
29import importTasks from "./controllers/import-tasks";
30import moveTask from "./controllers/move-task";
31import updateTask from "./controllers/update-task";
32import updateTaskAssignee from "./controllers/update-task-assignee";
33import updateTaskDescription from "./controllers/update-task-description";
34import updateTaskDueDate from "./controllers/update-task-due-date";
35import updateTaskPriority from "./controllers/update-task-priority";
36import updateTaskStatus from "./controllers/update-task-status";
37import updateTaskTitle from "./controllers/update-task-title";
38import { VALID_PRIORITIES } from "./validate-task-fields";
39
40const task = new Hono<{
41 Variables: {
42 userId: string;
43 };
44}>()
45 .get(
46 "/tasks/:projectId",
47 describeRoute({
48 operationId: "listTasks",
49 tags: ["Tasks"],
50 description: "Get all tasks for a specific project",
51 responses: {
52 200: {
53 description: "Project with tasks organized by columns",
54 content: {
55 "application/json": { schema: resolver(v.any()) },
56 },
57 },
58 },
59 }),
60 validator("param", v.object({ projectId: v.string() })),
61 validator(
62 "query",
63 v.optional(
64 v.object({
65 status: v.optional(v.string()),
66 priority: v.optional(v.string()),
67 assigneeId: v.optional(v.string()),
68 page: v.optional(v.pipe(v.string(), v.transform(Number))),
69 limit: v.optional(v.pipe(v.string(), v.transform(Number))),
70 sortBy: v.optional(
71 v.picklist([
72 "createdAt",
73 "priority",
74 "dueDate",
75 "position",
76 "title",
77 "number",
78 ]),
79 ),
80 sortOrder: v.optional(v.picklist(["asc", "desc"])),
81 dueBefore: v.optional(v.string()),
82 dueAfter: v.optional(v.string()),
83 }),
84 ),
85 ),
86 workspaceAccess.fromProject("projectId"),
87 async (c) => {
88 const { projectId } = c.req.valid("param");
89 const filters = c.req.valid("query") || {};
90
91 const tasks = await getTasks(projectId, filters);
92
93 return c.json(tasks);
94 },
95 )
96 .patch(
97 "/bulk",
98 describeRoute({
99 operationId: "bulkUpdateTasks",
100 tags: ["Tasks"],
101 description: "Perform bulk operations on multiple tasks",
102 responses: {
103 200: {
104 description: "Bulk operation completed successfully",
105 content: {
106 "application/json": {
107 schema: resolver(
108 v.object({
109 success: v.boolean(),
110 updatedCount: v.number(),
111 }),
112 ),
113 },
114 },
115 },
116 },
117 }),
118 validator(
119 "json",
120 v.object({
121 taskIds: v.pipe(v.array(v.string()), v.minLength(1)),
122 operation: v.picklist([
123 "updateStatus",
124 "updatePriority",
125 "updateAssignee",
126 "delete",
127 "addLabel",
128 "removeLabel",
129 "updateDueDate",
130 ] as const),
131 value: v.optional(v.nullable(v.string())),
132 }),
133 ),
134 async (c) => {
135 const { taskIds, operation, value } = c.req.valid("json");
136 const userId = c.get("userId");
137
138 if (!userId) {
139 throw new HTTPException(401, { message: "Unauthorized" });
140 }
141
142 if (
143 operation !== "delete" &&
144 operation !== "updateDueDate" &&
145 value === undefined
146 ) {
147 throw new HTTPException(400, {
148 message: "Value is required for this operation",
149 });
150 }
151
152 const result = await bulkUpdateTasks({
153 taskIds,
154 operation,
155 value,
156 userId,
157 });
158
159 return c.json(result);
160 },
161 )
162 .post(
163 "/:projectId",
164 describeRoute({
165 operationId: "createTask",
166 tags: ["Tasks"],
167 description: "Create a new task in a project",
168 responses: {
169 200: {
170 description: "Task created successfully",
171 content: {
172 "application/json": { schema: resolver(taskSchema) },
173 },
174 },
175 },
176 }),
177 validator(
178 "json",
179 v.object({
180 title: v.string(),
181 description: v.string(),
182 startDate: v.optional(v.string()),
183 dueDate: v.optional(v.string()),
184 priority: v.picklist(VALID_PRIORITIES),
185 status: v.string(),
186 userId: v.optional(v.string()),
187 }),
188 ),
189 workspaceAccess.fromProject("projectId"),
190 async (c) => {
191 const { projectId } = c.req.param();
192 const {
193 title,
194 description,
195 startDate,
196 dueDate,
197 priority,
198 status,
199 userId,
200 } = c.req.valid("json");
201
202 const task = await createTask({
203 projectId,
204 userId,
205 title,
206 description,
207 startDate: startDate ? new Date(startDate) : undefined,
208 dueDate: dueDate ? new Date(dueDate) : undefined,
209 priority,
210 status,
211 });
212
213 return c.json(task);
214 },
215 )
216 .get(
217 "/:id",
218 describeRoute({
219 operationId: "getTask",
220 tags: ["Tasks"],
221 description: "Get a specific task by ID",
222 responses: {
223 200: {
224 description: "Task details",
225 content: {
226 "application/json": { schema: resolver(taskSchema) },
227 },
228 },
229 },
230 }),
231 validator("param", v.object({ id: v.string() })),
232 workspaceAccess.fromTask(),
233 async (c) => {
234 const { id } = c.req.valid("param");
235
236 const task = await getTask(id);
237
238 return c.json(task);
239 },
240 )
241 .put(
242 "/move/:id",
243 describeRoute({
244 operationId: "moveTask",
245 tags: ["Tasks"],
246 description: "Move a task to another project",
247 responses: {
248 200: {
249 description: "Task moved successfully",
250 content: {
251 "application/json": {
252 schema: resolver(
253 v.object({
254 task: taskSchema,
255 sourceProjectId: v.string(),
256 destinationProjectId: v.string(),
257 }),
258 ),
259 },
260 },
261 },
262 },
263 }),
264 validator("param", v.object({ id: v.string() })),
265 validator(
266 "json",
267 v.object({
268 destinationProjectId: v.string(),
269 destinationStatus: v.optional(v.string()),
270 }),
271 ),
272 workspaceAccess.fromTask(),
273 async (c) => {
274 const { id } = c.req.valid("param");
275 const { destinationProjectId, destinationStatus } = c.req.valid("json");
276 const userId = c.get("userId");
277
278 const result = await moveTask({
279 taskId: id,
280 destinationProjectId,
281 destinationStatus,
282 userId,
283 });
284
285 return c.json(result);
286 },
287 )
288 .put(
289 "/:id",
290 describeRoute({
291 operationId: "updateTask",
292 tags: ["Tasks"],
293 description: "Update all fields of a task",
294 responses: {
295 200: {
296 description: "Task updated successfully",
297 content: {
298 "application/json": { schema: resolver(taskSchema) },
299 },
300 },
301 },
302 }),
303 validator("param", v.object({ id: v.string() })),
304 validator(
305 "json",
306 v.object({
307 title: v.string(),
308 description: v.string(),
309 startDate: v.optional(v.string()),
310 dueDate: v.optional(v.string()),
311 priority: v.picklist(VALID_PRIORITIES),
312 status: v.string(),
313 projectId: v.string(),
314 position: v.number(),
315 userId: v.optional(v.string()),
316 }),
317 ),
318 workspaceAccess.fromTask(),
319 async (c) => {
320 const { id } = c.req.valid("param");
321 const existingTask = await db.query.taskTable.findFirst({
322 where: eq(taskTable.id, id),
323 });
324 const {
325 title,
326 description,
327 startDate,
328 dueDate,
329 priority,
330 status,
331 projectId,
332 position,
333 userId,
334 } = c.req.valid("json");
335
336 if (!existingTask) {
337 throw new HTTPException(404, { message: "Task not found" });
338 }
339
340 const task = await updateTask(
341 id,
342 title,
343 status,
344 startDate ? new Date(startDate) : undefined,
345 dueDate ? new Date(dueDate) : undefined,
346 projectId,
347 description,
348 priority,
349 position,
350 userId,
351 );
352
353 if (existingTask.status !== status) {
354 const user = c.get("userId");
355 await publishEvent("task.status_changed", {
356 taskId: task.id,
357 projectId: task.projectId,
358 userId: user,
359 oldStatus: existingTask.status,
360 newStatus: status,
361 title: task.title,
362 assigneeId: task.userId,
363 type: "status_changed",
364 });
365 }
366
367 return c.json(task);
368 },
369 )
370 .get(
371 "/export/:projectId",
372 describeRoute({
373 operationId: "exportTasks",
374 tags: ["Tasks"],
375 description: "Export all tasks from a project",
376 responses: {
377 200: {
378 description: "Exported tasks data",
379 content: {
380 "application/json": { schema: resolver(v.any()) },
381 },
382 },
383 },
384 }),
385 validator("param", v.object({ projectId: v.string() })),
386 workspaceAccess.fromProject("projectId"),
387 async (c) => {
388 const { projectId } = c.req.valid("param");
389
390 const exportData = await exportTasks(projectId);
391
392 return c.json(exportData);
393 },
394 )
395 .post(
396 "/import/:projectId",
397 describeRoute({
398 operationId: "importTasks",
399 tags: ["Tasks"],
400 description: "Import multiple tasks into a project",
401 responses: {
402 200: {
403 description: "Tasks imported successfully",
404 content: {
405 "application/json": { schema: resolver(v.any()) },
406 },
407 },
408 },
409 }),
410 validator("param", v.object({ projectId: v.string() })),
411 validator(
412 "json",
413 v.object({
414 tasks: v.array(
415 v.object({
416 title: v.string(),
417 description: v.optional(v.string()),
418 status: v.string(),
419 priority: v.optional(v.string()),
420 startDate: v.optional(v.string()),
421 dueDate: v.optional(v.string()),
422 userId: v.optional(v.nullable(v.string())),
423 }),
424 ),
425 }),
426 ),
427 workspaceAccess.fromProject("projectId"),
428 async (c) => {
429 const { projectId } = c.req.valid("param");
430 const { tasks } = c.req.valid("json");
431
432 const result = await importTasks(projectId, tasks);
433
434 return c.json(result);
435 },
436 )
437 .delete(
438 "/:id",
439 describeRoute({
440 operationId: "deleteTask",
441 tags: ["Tasks"],
442 description: "Delete a task by ID",
443 responses: {
444 200: {
445 description: "Task deleted successfully",
446 content: {
447 "application/json": { schema: resolver(taskSchema) },
448 },
449 },
450 },
451 }),
452 validator("param", v.object({ id: v.string() })),
453 workspaceAccess.fromTask(),
454 async (c) => {
455 const { id } = c.req.valid("param");
456
457 const task = await deleteTask(id);
458
459 return c.json(task);
460 },
461 )
462 .put(
463 "/status/:id",
464 describeRoute({
465 operationId: "updateTaskStatus",
466 tags: ["Tasks"],
467 description: "Update only the status of a task",
468 responses: {
469 200: {
470 description: "Task status updated successfully",
471 content: {
472 "application/json": { schema: resolver(taskSchema) },
473 },
474 },
475 },
476 }),
477 validator("param", v.object({ id: v.string() })),
478 validator("json", v.object({ status: v.string() })),
479 workspaceAccess.fromTask(),
480 async (c) => {
481 const { id } = c.req.valid("param");
482 const { status } = c.req.valid("json");
483 const user = c.get("userId");
484 const existingTask = await db.query.taskTable.findFirst({
485 where: eq(taskTable.id, id),
486 });
487
488 if (!existingTask) {
489 throw new HTTPException(404, { message: "Task not found" });
490 }
491
492 const task = await updateTaskStatus({ id, status });
493
494 await publishEvent("task.status_changed", {
495 taskId: task.id,
496 projectId: task.projectId,
497 userId: user,
498 oldStatus: existingTask.status,
499 newStatus: status,
500 title: task.title,
501 assigneeId: task.userId,
502 type: "status_changed",
503 });
504
505 return c.json(task);
506 },
507 )
508 .put(
509 "/priority/:id",
510 describeRoute({
511 operationId: "updateTaskPriority",
512 tags: ["Tasks"],
513 description: "Update only the priority of a task",
514 responses: {
515 200: {
516 description: "Task priority updated successfully",
517 content: {
518 "application/json": { schema: resolver(taskSchema) },
519 },
520 },
521 },
522 }),
523 validator("param", v.object({ id: v.string() })),
524 validator("json", v.object({ priority: v.picklist(VALID_PRIORITIES) })),
525 workspaceAccess.fromTask(),
526 async (c) => {
527 const { id } = c.req.valid("param");
528 const { priority } = c.req.valid("json");
529 const user = c.get("userId");
530 const existingTask = await db.query.taskTable.findFirst({
531 where: eq(taskTable.id, id),
532 });
533
534 if (!existingTask) {
535 throw new HTTPException(404, { message: "Task not found" });
536 }
537
538 const task = await updateTaskPriority({ id, priority });
539
540 await publishEvent("task.priority_changed", {
541 taskId: task.id,
542 projectId: task.projectId,
543 userId: user,
544 oldPriority: existingTask.priority,
545 newPriority: priority,
546 title: task.title,
547 type: "priority_changed",
548 });
549
550 return c.json(task);
551 },
552 )
553 .put(
554 "/assignee/:id",
555 describeRoute({
556 operationId: "updateTaskAssignee",
557 tags: ["Tasks"],
558 description: "Assign or unassign a task to a user",
559 responses: {
560 200: {
561 description: "Task assignee updated successfully",
562 content: {
563 "application/json": { schema: resolver(taskSchema) },
564 },
565 },
566 },
567 }),
568 validator("param", v.object({ id: v.string() })),
569 validator("json", v.object({ userId: v.string() })),
570 workspaceAccess.fromTask(),
571 async (c) => {
572 const { id } = c.req.valid("param");
573 const { userId } = c.req.valid("json");
574 const user = c.get("userId");
575 const existingTask = await db.query.taskTable.findFirst({
576 where: eq(taskTable.id, id),
577 });
578
579 if (!existingTask) {
580 throw new HTTPException(404, { message: "Task not found" });
581 }
582
583 const task = await updateTaskAssignee({ id, userId });
584 const newAssigneeName = userId
585 ? (
586 await db
587 .select({ name: userTable.name })
588 .from(userTable)
589 .where(eq(userTable.id, userId))
590 .limit(1)
591 )[0]?.name
592 : undefined;
593
594 if (!userId) {
595 await publishEvent("task.unassigned", {
596 taskId: task.id,
597 userId: user,
598 title: task.title,
599 type: "unassigned",
600 });
601 return c.json(task);
602 }
603
604 await publishEvent("task.assignee_changed", {
605 taskId: task.id,
606 userId: user,
607 oldAssignee: existingTask.userId,
608 newAssignee: newAssigneeName,
609 newAssigneeId: userId,
610 title: task.title,
611 type: "assignee_changed",
612 });
613
614 return c.json(task);
615 },
616 )
617 .put(
618 "/due-date/:id",
619 describeRoute({
620 operationId: "updateTaskDueDate",
621 tags: ["Tasks"],
622 description: "Update only the due date of a task",
623 responses: {
624 200: {
625 description: "Task due date updated successfully",
626 content: {
627 "application/json": { schema: resolver(taskSchema) },
628 },
629 },
630 },
631 }),
632 validator("param", v.object({ id: v.string() })),
633 validator("json", v.object({ dueDate: v.optional(v.string()) })),
634 workspaceAccess.fromTask(),
635 async (c) => {
636 const { id } = c.req.valid("param");
637 const { dueDate = null } = c.req.valid("json");
638 const user = c.get("userId");
639 const existingTask = await db.query.taskTable.findFirst({
640 where: eq(taskTable.id, id),
641 });
642
643 if (!existingTask) {
644 throw new HTTPException(404, { message: "Task not found" });
645 }
646
647 const task = await updateTaskDueDate({
648 id,
649 dueDate: dueDate ? new Date(dueDate) : null,
650 });
651
652 await publishEvent("task.due_date_changed", {
653 taskId: task.id,
654 userId: user,
655 oldDueDate: existingTask.dueDate,
656 newDueDate: dueDate,
657 title: task.title,
658 type: "due_date_changed",
659 });
660
661 return c.json(task);
662 },
663 )
664
665 .put(
666 "/title/:id",
667 describeRoute({
668 operationId: "updateTaskTitle",
669 tags: ["Tasks"],
670 description: "Update only the title of a task",
671 responses: {
672 200: {
673 description: "Task title updated successfully",
674 content: {
675 "application/json": { schema: resolver(taskSchema) },
676 },
677 },
678 },
679 }),
680 validator("param", v.object({ id: v.string() })),
681 validator("json", v.object({ title: v.string() })),
682 workspaceAccess.fromTask(),
683 async (c) => {
684 const { id } = c.req.valid("param");
685 const { title } = c.req.valid("json");
686 const user = c.get("userId");
687
688 // Fetch task BEFORE update to get old title
689 const existingTask = await db.query.taskTable.findFirst({
690 where: eq(taskTable.id, id),
691 });
692
693 if (!existingTask) {
694 throw new HTTPException(404, { message: "Task not found" });
695 }
696
697 const task = await updateTaskTitle({ id, title });
698
699 await publishEvent("task.title_changed", {
700 taskId: task.id,
701 projectId: task.projectId,
702 userId: user,
703 oldTitle: existingTask.title,
704 newTitle: title,
705 type: "title_changed",
706 });
707
708 return c.json(task);
709 },
710 )
711
712 .put(
713 "/image-upload/:id",
714 describeRoute({
715 operationId: "createTaskImageUpload",
716 tags: ["Tasks"],
717 description:
718 "Create a presigned image upload URL for a task description or comment",
719 responses: {
720 200: {
721 description: "Image upload URL created successfully",
722 content: {
723 "application/json": { schema: resolver(v.any()) },
724 },
725 },
726 },
727 }),
728 validator("param", v.object({ id: v.string() })),
729 validator(
730 "json",
731 v.object({
732 filename: v.string(),
733 contentType: v.string(),
734 size: v.number(),
735 surface: v.picklist(["description", "comment"] as const),
736 }),
737 ),
738 workspaceAccess.fromTask(),
739 async (c) => {
740 const { id } = c.req.valid("param");
741 const { filename, contentType, size, surface } = c.req.valid("json");
742
743 try {
744 validateTaskAssetUploadInput(contentType, size);
745 } catch (error) {
746 throw new HTTPException(400, {
747 message:
748 error instanceof Error
749 ? error.message
750 : "Invalid image upload request",
751 });
752 }
753
754 const [taskContext] = await db
755 .select({
756 taskId: taskTable.id,
757 projectId: taskTable.projectId,
758 workspaceId: workspaceTable.id,
759 })
760 .from(taskTable)
761 .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id))
762 .innerJoin(
763 workspaceTable,
764 eq(projectTable.workspaceId, workspaceTable.id),
765 )
766 .where(eq(taskTable.id, id))
767 .limit(1);
768
769 if (!taskContext) {
770 throw new HTTPException(404, { message: "Task not found" });
771 }
772
773 try {
774 const upload = await createTaskImageUploadUrl({
775 workspaceId: taskContext.workspaceId,
776 projectId: taskContext.projectId,
777 taskId: taskContext.taskId,
778 surface,
779 filename,
780 contentType,
781 });
782
783 return c.json(upload);
784 } catch (error) {
785 throw new HTTPException(503, {
786 message:
787 error instanceof Error
788 ? error.message
789 : "Image uploads are not configured",
790 });
791 }
792 },
793 )
794 .post(
795 "/image-upload/:id/finalize",
796 describeRoute({
797 operationId: "finalizeTaskImageUpload",
798 tags: ["Tasks"],
799 description:
800 "Finalize an uploaded task image and create a private asset record",
801 responses: {
802 200: {
803 description: "Image upload finalized successfully",
804 content: {
805 "application/json": { schema: resolver(v.any()) },
806 },
807 },
808 },
809 }),
810 validator("param", v.object({ id: v.string() })),
811 validator(
812 "json",
813 v.object({
814 key: v.string(),
815 filename: v.string(),
816 contentType: v.string(),
817 size: v.number(),
818 surface: v.picklist(["description", "comment"] as const),
819 }),
820 ),
821 workspaceAccess.fromTask(),
822 async (c) => {
823 const { id } = c.req.valid("param");
824 const { key, filename, contentType, size, surface } = c.req.valid("json");
825 const userId = c.get("userId");
826
827 try {
828 validateTaskAssetUploadInput(contentType, size);
829 } catch (error) {
830 throw new HTTPException(400, {
831 message:
832 error instanceof Error
833 ? error.message
834 : "Invalid image upload request",
835 });
836 }
837
838 const [taskContext] = await db
839 .select({
840 taskId: taskTable.id,
841 projectId: taskTable.projectId,
842 workspaceId: workspaceTable.id,
843 })
844 .from(taskTable)
845 .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id))
846 .innerJoin(
847 workspaceTable,
848 eq(projectTable.workspaceId, workspaceTable.id),
849 )
850 .where(eq(taskTable.id, id))
851 .limit(1);
852
853 if (!taskContext) {
854 throw new HTTPException(404, { message: "Task not found" });
855 }
856
857 const normalizedKey = key.trim();
858 if (
859 !assertTaskImageKeyMatchesContext(normalizedKey, {
860 workspaceId: taskContext.workspaceId,
861 projectId: taskContext.projectId,
862 taskId: taskContext.taskId,
863 surface,
864 })
865 ) {
866 throw new HTTPException(400, {
867 message: "Image upload key does not match the task context.",
868 });
869 }
870
871 const [existingAsset] = await db
872 .select({ id: assetTable.id })
873 .from(assetTable)
874 .where(eq(assetTable.objectKey, normalizedKey))
875 .limit(1);
876
877 const [asset] = existingAsset
878 ? await db
879 .update(assetTable)
880 .set({
881 workspaceId: taskContext.workspaceId,
882 projectId: taskContext.projectId,
883 taskId: taskContext.taskId,
884 filename,
885 mimeType: contentType,
886 size,
887 kind: isImageContentType(contentType) ? "image" : "attachment",
888 surface,
889 createdBy: userId || null,
890 })
891 .where(eq(assetTable.id, existingAsset.id))
892 .returning({
893 id: assetTable.id,
894 })
895 : await db
896 .insert(assetTable)
897 .values({
898 workspaceId: taskContext.workspaceId,
899 projectId: taskContext.projectId,
900 taskId: taskContext.taskId,
901 objectKey: normalizedKey,
902 filename,
903 mimeType: contentType,
904 size,
905 kind: isImageContentType(contentType) ? "image" : "attachment",
906 surface,
907 createdBy: userId || null,
908 })
909 .returning({
910 id: assetTable.id,
911 });
912
913 return c.json({
914 id: asset.id,
915 url: new URL(`/api/asset/${asset.id}`, c.req.url).toString(),
916 });
917 },
918 )
919 .put(
920 "/description/:id",
921 describeRoute({
922 operationId: "updateTaskDescription",
923 tags: ["Tasks"],
924 description: "Update only the description of a task",
925 responses: {
926 200: {
927 description: "Task description updated successfully",
928 content: {
929 "application/json": { schema: resolver(taskSchema) },
930 },
931 },
932 },
933 }),
934 validator("param", v.object({ id: v.string() })),
935 validator("json", v.object({ description: v.string() })),
936 workspaceAccess.fromTask(),
937 async (c) => {
938 const { id } = c.req.valid("param");
939 const { description } = c.req.valid("json");
940 const user = c.get("userId");
941
942 // Fetch task BEFORE update to get old description
943 const existingTask = await db.query.taskTable.findFirst({
944 where: eq(taskTable.id, id),
945 });
946
947 if (!existingTask) {
948 throw new HTTPException(404, { message: "Task not found" });
949 }
950
951 const task = await updateTaskDescription({ id, description });
952
953 await publishEvent("task.description_changed", {
954 taskId: task.id,
955 projectId: task.projectId,
956 userId: user,
957 oldDescription: existingTask.description,
958 newDescription: description,
959 type: "description_changed",
960 });
961
962 return c.json(task);
963 },
964 );
965
966export default task;