kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq, inArray } from "drizzle-orm";
2import { HTTPException } from "hono/http-exception";
3import db from "../../database";
4import {
5 columnTable,
6 labelTable,
7 projectTable,
8 taskTable,
9 workspaceUserTable,
10} from "../../database/schema";
11import {
12 assertValidPriority,
13 assertValidTaskStatus,
14} from "../validate-task-fields";
15
16type BulkOperation =
17 | "updateStatus"
18 | "updatePriority"
19 | "updateAssignee"
20 | "delete"
21 | "addLabel"
22 | "removeLabel"
23 | "updateDueDate";
24
25async function bulkUpdateTasks({
26 taskIds,
27 operation,
28 value,
29 userId,
30}: {
31 taskIds: string[];
32 operation: BulkOperation;
33 value?: string | null;
34 userId: string;
35}) {
36 const tasks = await db
37 .select({
38 id: taskTable.id,
39 projectId: taskTable.projectId,
40 workspaceId: projectTable.workspaceId,
41 })
42 .from(taskTable)
43 .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id))
44 .where(inArray(taskTable.id, taskIds));
45
46 if (tasks.length === 0) {
47 throw new HTTPException(404, {
48 message: "No tasks found",
49 });
50 }
51
52 const workspaceIds = [...new Set(tasks.map((t) => t.workspaceId))];
53
54 if (workspaceIds.length > 1) {
55 throw new HTTPException(400, {
56 message: "All tasks must belong to the same workspace",
57 });
58 }
59
60 const workspaceId = workspaceIds[0];
61
62 if (!workspaceId) {
63 throw new HTTPException(400, {
64 message: "Could not determine workspace",
65 });
66 }
67
68 const [membership] = await db
69 .select({ id: workspaceUserTable.id })
70 .from(workspaceUserTable)
71 .where(
72 and(
73 eq(workspaceUserTable.userId, userId),
74 eq(workspaceUserTable.workspaceId, workspaceId),
75 ),
76 )
77 .limit(1);
78
79 if (!membership) {
80 throw new HTTPException(403, {
81 message: "You don't have access to this workspace",
82 });
83 }
84
85 const foundIds = tasks.map((t) => t.id);
86 let updatedCount = 0;
87
88 switch (operation) {
89 case "updateStatus": {
90 if (!value) {
91 throw new HTTPException(400, { message: "Status value is required" });
92 }
93 const projectIds = [...new Set(tasks.map((t) => t.projectId))];
94
95 for (const projectId of projectIds) {
96 await assertValidTaskStatus(value, projectId);
97
98 const column = await db.query.columnTable.findFirst({
99 where: and(
100 eq(columnTable.projectId, projectId),
101 eq(columnTable.slug, value),
102 ),
103 });
104
105 const projectTaskIds = tasks
106 .filter((t) => t.projectId === projectId)
107 .map((t) => t.id);
108
109 const result = await db
110 .update(taskTable)
111 .set({ status: value, columnId: column?.id ?? null })
112 .where(inArray(taskTable.id, projectTaskIds));
113
114 updatedCount += result.rowCount ?? projectTaskIds.length;
115 }
116 break;
117 }
118
119 case "updatePriority": {
120 if (!value) {
121 throw new HTTPException(400, { message: "Priority value is required" });
122 }
123 assertValidPriority(value);
124
125 const result = await db
126 .update(taskTable)
127 .set({ priority: value })
128 .where(inArray(taskTable.id, foundIds));
129
130 updatedCount = result.rowCount ?? foundIds.length;
131 break;
132 }
133
134 case "updateAssignee": {
135 const result = await db
136 .update(taskTable)
137 .set({ userId: value || null })
138 .where(inArray(taskTable.id, foundIds));
139
140 updatedCount = result.rowCount ?? foundIds.length;
141 break;
142 }
143
144 case "delete": {
145 const result = await db
146 .delete(taskTable)
147 .where(inArray(taskTable.id, foundIds));
148
149 updatedCount = result.rowCount ?? foundIds.length;
150 break;
151 }
152
153 case "addLabel": {
154 if (!value) {
155 throw new HTTPException(400, { message: "Label ID is required" });
156 }
157
158 const label = await db.query.labelTable.findFirst({
159 where: eq(labelTable.id, value),
160 });
161
162 if (!label) {
163 throw new HTTPException(404, { message: "Label not found" });
164 }
165
166 for (const task of tasks) {
167 const existingAssignment = await db.query.labelTable.findFirst({
168 where: and(
169 eq(labelTable.name, label.name),
170 eq(labelTable.taskId, task.id),
171 ),
172 });
173
174 if (!existingAssignment) {
175 await db
176 .insert(labelTable)
177 .values({
178 name: label.name,
179 color: label.color,
180 workspaceId: workspaceId,
181 taskId: task.id,
182 })
183 .onConflictDoNothing({
184 target: [labelTable.taskId, labelTable.name],
185 });
186 updatedCount++;
187 }
188 }
189 break;
190 }
191
192 case "removeLabel": {
193 if (!value) {
194 throw new HTTPException(400, { message: "Label ID is required" });
195 }
196 const result = await db
197 .update(labelTable)
198 .set({ taskId: null })
199 .where(
200 and(eq(labelTable.id, value), inArray(labelTable.taskId, foundIds)),
201 );
202
203 updatedCount = result.rowCount ?? foundIds.length;
204 break;
205 }
206
207 case "updateDueDate": {
208 let parsedDate: Date | null = null;
209 if (value) {
210 parsedDate = new Date(value);
211 if (Number.isNaN(parsedDate.getTime())) {
212 throw new HTTPException(400, {
213 message: `Invalid date value "${value}"`,
214 });
215 }
216 }
217
218 const result = await db
219 .update(taskTable)
220 .set({ dueDate: parsedDate })
221 .where(inArray(taskTable.id, foundIds));
222
223 updatedCount = result.rowCount ?? foundIds.length;
224 break;
225 }
226
227 default: {
228 throw new HTTPException(400, {
229 message: `Unknown operation "${operation}"`,
230 });
231 }
232 }
233
234 return { success: true, updatedCount };
235}
236
237export default bulkUpdateTasks;