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 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;