kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cd7cada2f86b4e866a15b4323bb8d6d7ab5bba8b 966 lines 26 kB view raw
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;