kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
at main 339 lines 8.4 kB view raw
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;