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
16const activity = new Hono<{
17 Variables: {
18 userId: string;
19 };
20}>()
21 .get(
22 "/:taskId",
23 describeRoute({
24 operationId: "getActivities",
25 tags: ["Activity"],
26 description: "Get all activities for a specific task",
27 responses: {
28 200: {
29 description: "List of activities for the task",
30 content: {
31 "application/json": { schema: resolver(v.array(activitySchema)) },
32 },
33 },
34 },
35 }),
36 validator("param", v.object({ taskId: v.string() })),
37 workspaceAccess.fromTaskId(),
38 async (c) => {
39 const { taskId } = c.req.valid("param");
40 const activities = await getActivities(taskId);
41 return c.json(activities);
42 },
43 )
44 .post(
45 "/create",
46 describeRoute({
47 operationId: "createActivity",
48 tags: ["Activity"],
49 description: "Create a new activity (system-generated event)",
50 responses: {
51 200: {
52 description: "Activity created successfully",
53 content: {
54 "application/json": { schema: resolver(activitySchema) },
55 },
56 },
57 },
58 }),
59 validator(
60 "json",
61 v.object({
62 taskId: v.string(),
63 userId: v.string(),
64 message: v.nullable(v.string()),
65 type: v.string(),
66 eventData: v.optional(v.nullable(v.record(v.string(), v.unknown()))),
67 }),
68 ),
69 workspaceAccess.fromTaskId(),
70 async (c) => {
71 const { taskId, userId, message, type, eventData } = c.req.valid("json");
72 const activity = await createActivity(
73 taskId,
74 type,
75 userId,
76 message,
77 eventData,
78 );
79 return c.json(activity);
80 },
81 )
82 .post(
83 "/comment",
84 describeRoute({
85 operationId: "createComment",
86 tags: ["Activity"],
87 description: "Create a new comment on a task",
88 responses: {
89 200: {
90 description: "Comment created successfully",
91 content: {
92 "application/json": { schema: resolver(activitySchema) },
93 },
94 },
95 },
96 }),
97 validator(
98 "json",
99 v.object({
100 taskId: v.string(),
101 comment: v.string(),
102 }),
103 ),
104 workspaceAccess.fromTaskId(),
105 async (c) => {
106 const { taskId, comment } = c.req.valid("json");
107 const userId = c.get("userId");
108 const newComment = await createComment(taskId, userId, comment);
109
110 const [user] = await db
111 .select({ name: userTable.name })
112 .from(userTable)
113 .where(eq(userTable.id, userId));
114
115 const [task] = await db
116 .select({ projectId: taskTable.projectId })
117 .from(taskTable)
118 .where(eq(taskTable.id, taskId));
119
120 if (task) {
121 await publishEvent("task.comment_created", {
122 taskId,
123 userId,
124 comment: `"${user?.name}" commented: ${comment}`,
125 projectId: task.projectId,
126 });
127 }
128
129 return c.json(newComment);
130 },
131 )
132 .put(
133 "/comment",
134 describeRoute({
135 operationId: "updateComment",
136 tags: ["Activity"],
137 description: "Update an existing comment",
138 responses: {
139 200: {
140 description: "Comment updated successfully",
141 content: {
142 "application/json": { schema: resolver(activitySchema) },
143 },
144 },
145 },
146 }),
147 validator(
148 "json",
149 v.object({
150 activityId: v.string(),
151 comment: v.string(),
152 }),
153 ),
154 workspaceAccess.fromActivity("activityId"),
155 async (c) => {
156 const { activityId, comment } = c.req.valid("json");
157 const userId = c.get("userId");
158 const updatedComment = await updateComment(userId, activityId, comment);
159 return c.json(updatedComment);
160 },
161 )
162 .delete(
163 "/comment",
164 describeRoute({
165 operationId: "deleteComment",
166 tags: ["Activity"],
167 description: "Delete a comment",
168 responses: {
169 200: {
170 description: "Comment deleted successfully",
171 content: {
172 "application/json": { schema: resolver(activitySchema) },
173 },
174 },
175 },
176 }),
177 validator(
178 "json",
179 v.object({
180 activityId: v.string(),
181 }),
182 ),
183 workspaceAccess.fromActivity("activityId"),
184 async (c) => {
185 const { activityId } = c.req.valid("json");
186 const userId = c.get("userId");
187 const deletedComment = await deleteComment(userId, activityId);
188 return c.json(deletedComment);
189 },
190 );
191
192subscribeToEvent<{
193 taskId: string;
194 userId: string;
195 type: string;
196 content: string | null;
197}>("task.created", async (data) => {
198 if (!data.userId || !data.taskId || !data.type) {
199 return;
200 }
201 await createActivity(data.taskId, data.type, data.userId, null, {});
202});
203
204subscribeToEvent<{
205 taskId: string;
206 userId: string;
207 type: string;
208 content: string;
209 eventData: {
210 fromProjectId: string;
211 fromProjectName: string;
212 toProjectId: string;
213 toProjectName: string;
214 oldStatus: string;
215 newStatus: string;
216 };
217}>("task.moved", async (data) => {
218 await createActivity(data.taskId, data.type, data.userId, data.content, {
219 fromProjectId: data.eventData.fromProjectId,
220 fromProjectName: data.eventData.fromProjectName,
221 toProjectId: data.eventData.toProjectId,
222 toProjectName: data.eventData.toProjectName,
223 oldStatus: data.eventData.oldStatus,
224 newStatus: data.eventData.newStatus,
225 });
226});
227
228subscribeToEvent<{
229 taskId: string;
230 userId: string;
231 oldStatus: string;
232 newStatus: string;
233 title: string;
234 assigneeId?: string;
235 type: string;
236}>("task.status_changed", async (data) => {
237 await createActivity(data.taskId, data.type, data.userId, null, {
238 oldStatus: data.oldStatus,
239 newStatus: data.newStatus,
240 });
241});
242
243subscribeToEvent<{
244 taskId: string;
245 userId: string;
246 oldPriority: string;
247 newPriority: string;
248 title: string;
249 type: string;
250}>("task.priority_changed", async (data) => {
251 await createActivity(data.taskId, data.type, data.userId, null, {
252 oldPriority: data.oldPriority,
253 newPriority: data.newPriority,
254 });
255});
256
257subscribeToEvent<{
258 taskId: string;
259 userId: string;
260 title: string;
261 type: string;
262}>("task.unassigned", async (data) => {
263 await createActivity(data.taskId, data.type, data.userId, null, {});
264});
265
266subscribeToEvent<{
267 taskId: string;
268 userId: string;
269 oldAssignee: string | null;
270 newAssignee: string;
271 newAssigneeId: string;
272 title: string;
273 type: string;
274}>("task.assignee_changed", async (data) => {
275 await createActivity(data.taskId, data.type, data.userId, null, {
276 newAssigneeId: data.newAssigneeId,
277 newAssignee: data.newAssignee,
278 isSelfAssigned: data.userId === data.newAssigneeId,
279 });
280});
281
282subscribeToEvent<{
283 taskId: string;
284 userId: string;
285 oldDueDate: Date | null;
286 newDueDate: Date;
287 title: string;
288 type: string;
289}>("task.due_date_changed", async (data) => {
290 await createActivity(data.taskId, data.type, data.userId, null, {
291 oldDueDate:
292 data.oldDueDate instanceof Date
293 ? data.oldDueDate.toISOString()
294 : data.oldDueDate,
295 newDueDate:
296 data.newDueDate instanceof Date
297 ? data.newDueDate.toISOString()
298 : data.newDueDate,
299 });
300});
301
302subscribeToEvent<{
303 taskId: string;
304 userId: string;
305 oldTitle: string;
306 newTitle: string;
307 title: string;
308 type: string;
309}>("task.title_changed", async (data) => {
310 await createActivity(data.taskId, data.type, data.userId, null, {
311 oldTitle: data.oldTitle,
312 newTitle: data.newTitle,
313 });
314});
315
316export default activity;