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