kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, asc, eq, max } from "drizzle-orm";
2import { HTTPException } from "hono/http-exception";
3import db from "../../database";
4import {
5 assetTable,
6 columnTable,
7 projectTable,
8 taskTable,
9} from "../../database/schema";
10import { publishEvent } from "../../events";
11import getNextTaskNumber from "./get-next-task-number";
12
13type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
14
15function isSameProjectMove(
16 sourceProjectId: string,
17 destinationProjectId: string,
18) {
19 return sourceProjectId === destinationProjectId;
20}
21
22async function resolveDestinationStatus(
23 destinationProjectId: string,
24 currentStatus: string,
25 requestedStatus?: string,
26) {
27 const destinationColumns = await db
28 .select({
29 id: columnTable.id,
30 slug: columnTable.slug,
31 position: columnTable.position,
32 })
33 .from(columnTable)
34 .where(eq(columnTable.projectId, destinationProjectId))
35 .orderBy(asc(columnTable.position));
36
37 if (destinationColumns.length === 0) {
38 throw new HTTPException(400, {
39 message: "Destination project does not have a workflow",
40 });
41 }
42
43 const requestedColumn = requestedStatus
44 ? destinationColumns.find((column) => column.slug === requestedStatus)
45 : null;
46
47 if (requestedStatus && !requestedColumn) {
48 throw new HTTPException(400, {
49 message: "Selected status is not valid for the destination project",
50 });
51 }
52
53 const matchingCurrentColumn = destinationColumns.find(
54 (column) => column.slug === currentStatus,
55 );
56
57 return requestedColumn ?? matchingCurrentColumn ?? destinationColumns[0];
58}
59
60async function getNextTaskPosition(
61 dbOrTx: DbOrTx,
62 projectId: string,
63 status: string,
64 columnId: string,
65) {
66 const [maxPositionResult] = await dbOrTx
67 .select({ maxPosition: max(taskTable.position) })
68 .from(taskTable)
69 .where(
70 and(
71 eq(taskTable.projectId, projectId),
72 eq(taskTable.status, status),
73 eq(taskTable.columnId, columnId),
74 ),
75 );
76
77 return (maxPositionResult?.maxPosition ?? 0) + 1;
78}
79
80async function moveTask({
81 taskId,
82 destinationProjectId,
83 destinationStatus,
84 userId,
85}: {
86 taskId: string;
87 destinationProjectId: string;
88 destinationStatus?: string;
89 userId: string;
90}) {
91 const existingTask = await db.query.taskTable.findFirst({
92 where: eq(taskTable.id, taskId),
93 });
94
95 if (!existingTask) {
96 throw new HTTPException(404, {
97 message: "Task not found",
98 });
99 }
100
101 if (isSameProjectMove(existingTask.projectId, destinationProjectId)) {
102 throw new HTTPException(400, {
103 message: "Task is already in that project",
104 });
105 }
106
107 const [sourceProject, destinationProject] = await Promise.all([
108 db.query.projectTable.findFirst({
109 where: eq(projectTable.id, existingTask.projectId),
110 }),
111 db.query.projectTable.findFirst({
112 where: eq(projectTable.id, destinationProjectId),
113 }),
114 ]);
115
116 if (!sourceProject || !destinationProject) {
117 throw new HTTPException(404, {
118 message: "Project not found",
119 });
120 }
121
122 if (sourceProject.workspaceId !== destinationProject.workspaceId) {
123 throw new HTTPException(400, {
124 message: "Tasks can only be moved within the same workspace",
125 });
126 }
127
128 const resolvedColumn = await resolveDestinationStatus(
129 destinationProjectId,
130 existingTask.status,
131 destinationStatus,
132 );
133
134 const movedTask = await db.transaction(async (tx) => {
135 const [nextTaskNumber, nextPosition] = await Promise.all([
136 getNextTaskNumber(destinationProjectId, tx),
137 getNextTaskPosition(
138 tx,
139 destinationProjectId,
140 resolvedColumn.slug,
141 resolvedColumn.id,
142 ),
143 ]);
144
145 const [updatedTask] = await tx
146 .update(taskTable)
147 .set({
148 projectId: destinationProjectId,
149 status: resolvedColumn.slug,
150 columnId: resolvedColumn.id,
151 number: nextTaskNumber + 1,
152 position: nextPosition,
153 })
154 .where(eq(taskTable.id, taskId))
155 .returning();
156
157 if (!updatedTask) {
158 throw new HTTPException(500, {
159 message: "Failed to move task",
160 });
161 }
162
163 await tx
164 .update(assetTable)
165 .set({ projectId: destinationProjectId })
166 .where(eq(assetTable.taskId, taskId));
167
168 return updatedTask;
169 });
170
171 await publishEvent("task.moved", {
172 taskId,
173 type: "task",
174 userId,
175 content: `Moved task from ${sourceProject.name} to ${destinationProject.name}`,
176 eventData: {
177 fromProjectId: sourceProject.id,
178 fromProjectName: sourceProject.name,
179 toProjectId: destinationProject.id,
180 toProjectName: destinationProject.name,
181 oldStatus: existingTask.status,
182 newStatus: resolvedColumn.slug,
183 },
184 });
185
186 return {
187 task: movedTask,
188 sourceProjectId: sourceProject.id,
189 destinationProjectId: destinationProject.id,
190 };
191}
192
193export default moveTask;