kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { eq } from "drizzle-orm";
2import { Hono } from "hono";
3import { describeRoute, resolver, validator } from "hono-openapi";
4import * as v from "valibot";
5import db from "../database";
6import { taskTable, userTable } from "../database/schema";
7import { publishEvent, subscribeToEvent } from "../events";
8import { activitySchema } from "../schemas";
9import { workspaceAccess } from "../utils/workspace-access-middleware";
10import createActivity from "./controllers/create-activity";
11import createComment from "./controllers/create-comment";
12import deleteComment from "./controllers/delete-comment";
13import getActivities from "./controllers/get-activities";
14import updateComment from "./controllers/update-comment";
15
16function toDisplayCase(value: string) {
17 return value
18 .replace(/[-_]/g, " ")
19 .split(" ")
20 .filter(Boolean)
21 .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
22 .join(" ");
23}
24
25function formatActivityDate(value: Date | string | null | undefined) {
26 if (!value) return null;
27 const date = value instanceof Date ? value : new Date(value);
28 if (Number.isNaN(date.getTime())) return null;
29
30 return new Intl.DateTimeFormat("en-US", {
31 month: "short",
32 day: "numeric",
33 year: "numeric",
34 }).format(date);
35}
36
37const activity = new Hono<{
38 Variables: {
39 userId: string;
40 };
41}>()
42 .get(
43 "/:taskId",
44 describeRoute({
45 operationId: "getActivities",
46 tags: ["Activity"],
47 description: "Get all activities for a specific task",
48 responses: {
49 200: {
50 description: "List of activities for the task",
51 content: {
52 "application/json": { schema: resolver(v.array(activitySchema)) },
53 },
54 },
55 },
56 }),
57 validator("param", v.object({ taskId: v.string() })),
58 workspaceAccess.fromTaskId(),
59 async (c) => {
60 const { taskId } = c.req.valid("param");
61 const activities = await getActivities(taskId);
62 return c.json(activities);
63 },
64 )
65 .post(
66 "/create",
67 describeRoute({
68 operationId: "createActivity",
69 tags: ["Activity"],
70 description: "Create a new activity (system-generated event)",
71 responses: {
72 200: {
73 description: "Activity created successfully",
74 content: {
75 "application/json": { schema: resolver(activitySchema) },
76 },
77 },
78 },
79 }),
80 validator(
81 "json",
82 v.object({
83 taskId: v.string(),
84 userId: v.string(),
85 message: v.string(),
86 type: v.string(),
87 }),
88 ),
89 workspaceAccess.fromTaskId(),
90 async (c) => {
91 const { taskId, userId, message, type } = c.req.valid("json");
92 const activity = await createActivity(taskId, type, userId, message);
93 return c.json(activity);
94 },
95 )
96 .post(
97 "/comment",
98 describeRoute({
99 operationId: "createComment",
100 tags: ["Activity"],
101 description: "Create a new comment on a task",
102 responses: {
103 200: {
104 description: "Comment created successfully",
105 content: {
106 "application/json": { schema: resolver(activitySchema) },
107 },
108 },
109 },
110 }),
111 validator(
112 "json",
113 v.object({
114 taskId: v.string(),
115 comment: v.string(),
116 }),
117 ),
118 workspaceAccess.fromTaskId(),
119 async (c) => {
120 const { taskId, comment } = c.req.valid("json");
121 const userId = c.get("userId");
122 const newComment = await createComment(taskId, userId, comment);
123
124 const [user] = await db
125 .select({ name: userTable.name })
126 .from(userTable)
127 .where(eq(userTable.id, userId));
128
129 const [task] = await db
130 .select({ projectId: taskTable.projectId })
131 .from(taskTable)
132 .where(eq(taskTable.id, taskId));
133
134 if (task) {
135 await publishEvent("task.comment_created", {
136 taskId,
137 userId,
138 comment: `"${user?.name}" commented: ${comment}`,
139 projectId: task.projectId,
140 });
141 }
142
143 return c.json(newComment);
144 },
145 )
146 .put(
147 "/comment",
148 describeRoute({
149 operationId: "updateComment",
150 tags: ["Activity"],
151 description: "Update an existing comment",
152 responses: {
153 200: {
154 description: "Comment updated successfully",
155 content: {
156 "application/json": { schema: resolver(activitySchema) },
157 },
158 },
159 },
160 }),
161 validator(
162 "json",
163 v.object({
164 activityId: v.string(),
165 comment: v.string(),
166 }),
167 ),
168 workspaceAccess.fromActivity("activityId"),
169 async (c) => {
170 const { activityId, comment } = c.req.valid("json");
171 const userId = c.get("userId");
172 const updatedComment = await updateComment(userId, activityId, comment);
173 return c.json(updatedComment);
174 },
175 )
176 .delete(
177 "/comment",
178 describeRoute({
179 operationId: "deleteComment",
180 tags: ["Activity"],
181 description: "Delete a comment",
182 responses: {
183 200: {
184 description: "Comment deleted successfully",
185 content: {
186 "application/json": { schema: resolver(activitySchema) },
187 },
188 },
189 },
190 }),
191 validator(
192 "json",
193 v.object({
194 activityId: v.string(),
195 }),
196 ),
197 workspaceAccess.fromActivity("activityId"),
198 async (c) => {
199 const { activityId } = c.req.valid("json");
200 const userId = c.get("userId");
201 const deletedComment = await deleteComment(userId, activityId);
202 return c.json(deletedComment);
203 },
204 );
205
206subscribeToEvent<{
207 taskId: string;
208 userId: string;
209 type: string;
210 content: string;
211}>("task.created", async (data) => {
212 if (!data.userId || !data.taskId || !data.type || !data.content) {
213 return;
214 }
215 await createActivity(data.taskId, data.type, data.userId, data.content);
216});
217
218subscribeToEvent<{
219 taskId: string;
220 userId: string;
221 oldStatus: string;
222 newStatus: string;
223 title: string;
224 assigneeId?: string;
225 type: string;
226}>("task.status_changed", async (data) => {
227 await createActivity(
228 data.taskId,
229 data.type,
230 data.userId,
231 `changed status from ${toDisplayCase(data.oldStatus)} to ${toDisplayCase(data.newStatus)}`,
232 );
233});
234
235subscribeToEvent<{
236 taskId: string;
237 userId: string;
238 oldPriority: string;
239 newPriority: string;
240 title: string;
241 type: string;
242}>("task.priority_changed", async (data) => {
243 await createActivity(
244 data.taskId,
245 data.type,
246 data.userId,
247 `changed priority from ${toDisplayCase(data.oldPriority)} to ${toDisplayCase(data.newPriority)}`,
248 );
249});
250
251subscribeToEvent<{
252 taskId: string;
253 userId: string;
254 title: string;
255 type: string;
256}>("task.unassigned", async (data) => {
257 await createActivity(
258 data.taskId,
259 data.type,
260 data.userId,
261 "unassigned the task",
262 );
263});
264
265subscribeToEvent<{
266 taskId: string;
267 userId: string;
268 oldAssignee: string | null;
269 newAssignee: string;
270 newAssigneeId: string;
271 title: string;
272 type: string;
273}>("task.assignee_changed", async (data) => {
274 if (data.userId === data.newAssigneeId) {
275 await createActivity(
276 data.taskId,
277 data.type,
278 data.userId,
279 "assigned the task to themselves",
280 );
281 return;
282 }
283
284 await createActivity(
285 data.taskId,
286 data.type,
287 data.userId,
288 `assigned the task to [[user:${data.newAssigneeId}|${data.newAssignee}]]`,
289 );
290});
291
292subscribeToEvent<{
293 taskId: string;
294 userId: string;
295 oldDueDate: Date | null;
296 newDueDate: Date;
297 title: string;
298 type: string;
299}>("task.due_date_changed", async (data) => {
300 const oldDate = formatActivityDate(data.oldDueDate) || "none";
301
302 if (!data.newDueDate) {
303 await createActivity(
304 data.taskId,
305 data.type,
306 data.userId,
307 "cleared the due date",
308 );
309 return;
310 }
311
312 const newDate = formatActivityDate(data.newDueDate);
313 if (!newDate) return;
314
315 await createActivity(
316 data.taskId,
317 data.type,
318 data.userId,
319 `changed due date from ${oldDate} to ${newDate}`,
320 );
321});
322
323subscribeToEvent<{
324 taskId: string;
325 userId: string;
326 oldTitle: string;
327 newTitle: string;
328 title: string;
329 type: string;
330}>("task.title_changed", async (data) => {
331 await createActivity(
332 data.taskId,
333 data.type,
334 data.userId,
335 `changed title from "${data.oldTitle}" to "${data.newTitle}"`,
336 );
337});
338
339export default activity;