kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq } from "drizzle-orm";
2import db from "../../database";
3import {
4 projectTable,
5 taskTable,
6 userTable,
7 workspaceTable,
8} from "../../database/schema";
9import type {
10 PluginContext,
11 TaskCommentCreatedEvent,
12 TaskCreatedEvent,
13 TaskDescriptionChangedEvent,
14 TaskPriorityChangedEvent,
15 TaskStatusChangedEvent,
16 TaskTitleChangedEvent,
17} from "../types";
18import { postToSlack } from "./client";
19import type { SlackConfig, SlackEventKey } from "./config";
20import { normalizeSlackConfig } from "./config";
21
22type SlackEventData = {
23 taskTitle: string;
24 taskNumber: number | null;
25 projectName: string;
26 taskUrl: string | null;
27 actorName: string | null;
28 status: string | null;
29 priority: string | null;
30};
31
32function isEnabled(config: SlackConfig, key: SlackEventKey): boolean {
33 return config.events?.[key] ?? false;
34}
35
36function escapeSlack(text: string): string {
37 return text
38 .replaceAll("&", "&")
39 .replaceAll("<", "<")
40 .replaceAll(">", ">");
41}
42
43function toSentenceCase(value: string | null): string {
44 if (!value) return "Unknown";
45 return value
46 .replace(/[-_]+/g, " ")
47 .replace(/\b\w/g, (char) => char.toUpperCase());
48}
49
50function truncate(value: string, maxLength: number): string {
51 if (value.length <= maxLength) {
52 return value;
53 }
54
55 return `${value.slice(0, maxLength - 1)}…`;
56}
57
58async function getSlackEventData(
59 taskId: string,
60 projectId: string,
61 userId: string | null,
62): Promise<SlackEventData | null> {
63 const [taskRow] = await db
64 .select({
65 title: taskTable.title,
66 number: taskTable.number,
67 status: taskTable.status,
68 priority: taskTable.priority,
69 projectName: projectTable.name,
70 projectId: projectTable.id,
71 workspaceId: workspaceTable.id,
72 })
73 .from(taskTable)
74 .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id))
75 .innerJoin(workspaceTable, eq(projectTable.workspaceId, workspaceTable.id))
76 .where(and(eq(taskTable.id, taskId), eq(projectTable.id, projectId)))
77 .limit(1);
78
79 if (!taskRow) {
80 return null;
81 }
82
83 const [user] = userId
84 ? await db
85 .select({ name: userTable.name })
86 .from(userTable)
87 .where(eq(userTable.id, userId))
88 .limit(1)
89 : [];
90
91 const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173";
92 const taskUrl = `${clientUrl}/dashboard/workspace/${taskRow.workspaceId}/project/${taskRow.projectId}/task/${taskId}`;
93
94 return {
95 taskTitle: taskRow.title,
96 taskNumber: taskRow.number,
97 projectName: taskRow.projectName,
98 taskUrl,
99 actorName: user?.name ?? null,
100 status: taskRow.status,
101 priority: taskRow.priority,
102 };
103}
104
105async function sendSlackMessage(
106 config: SlackConfig,
107 title: string,
108 body: string,
109 data: SlackEventData,
110): Promise<void> {
111 const issueKey =
112 data.taskNumber !== null ? `#${data.taskNumber}` : "Task update";
113 const escapedIssueKey = escapeSlack(issueKey);
114 const escapedTaskTitle = escapeSlack(data.taskTitle);
115 const taskLabel = data.taskUrl
116 ? `<${data.taskUrl}|${escapedIssueKey} ${escapedTaskTitle}>`
117 : `${escapedIssueKey} ${escapedTaskTitle}`;
118 const escapedTitle = escapeSlack(title);
119 const escapedBody = escapeSlack(body);
120
121 await postToSlack(config.webhookUrl, {
122 text: `${title}: ${data.taskTitle}`,
123 blocks: [
124 {
125 type: "section",
126 text: {
127 type: "mrkdwn",
128 text: `*${escapedTitle}*\n${escapedBody}`,
129 },
130 fields: [
131 {
132 type: "mrkdwn",
133 text: `*Task*\n${taskLabel}`,
134 },
135 {
136 type: "mrkdwn",
137 text: `*Project*\n${escapeSlack(data.projectName)}`,
138 },
139 {
140 type: "mrkdwn",
141 text: `*Status*\n${escapeSlack(toSentenceCase(data.status))}`,
142 },
143 {
144 type: "mrkdwn",
145 text: `*Priority*\n${escapeSlack(toSentenceCase(data.priority))}`,
146 },
147 ],
148 },
149 {
150 type: "context",
151 elements: [
152 {
153 type: "mrkdwn",
154 text: data.actorName
155 ? `Triggered by ${escapeSlack(data.actorName)}`
156 : "Triggered by Kaneo",
157 },
158 ],
159 },
160 ],
161 });
162}
163
164export async function handleTaskCreated(
165 event: TaskCreatedEvent,
166 context: PluginContext,
167): Promise<void> {
168 const config = normalizeSlackConfig(context.config as SlackConfig);
169 if (!isEnabled(config, "taskCreated")) return;
170
171 const data = await getSlackEventData(
172 event.taskId,
173 event.projectId,
174 event.userId,
175 );
176 if (!data) return;
177
178 await sendSlackMessage(
179 config,
180 "New task created",
181 `A new task was added: *${event.title}*`,
182 data,
183 );
184}
185
186export async function handleTaskStatusChanged(
187 event: TaskStatusChangedEvent,
188 context: PluginContext,
189): Promise<void> {
190 const config = normalizeSlackConfig(context.config as SlackConfig);
191 if (!isEnabled(config, "taskStatusChanged")) return;
192
193 const data = await getSlackEventData(
194 event.taskId,
195 event.projectId,
196 event.userId,
197 );
198 if (!data) return;
199
200 await sendSlackMessage(
201 config,
202 "Task status changed",
203 `*${event.title}* moved from *${toSentenceCase(event.oldStatus)}* to *${toSentenceCase(event.newStatus)}*.`,
204 data,
205 );
206}
207
208export async function handleTaskPriorityChanged(
209 event: TaskPriorityChangedEvent,
210 context: PluginContext,
211): Promise<void> {
212 const config = normalizeSlackConfig(context.config as SlackConfig);
213 if (!isEnabled(config, "taskPriorityChanged")) return;
214
215 const data = await getSlackEventData(
216 event.taskId,
217 event.projectId,
218 event.userId,
219 );
220 if (!data) return;
221
222 await sendSlackMessage(
223 config,
224 "Task priority changed",
225 `*${event.title}* changed from *${toSentenceCase(event.oldPriority)}* to *${toSentenceCase(event.newPriority)}*.`,
226 data,
227 );
228}
229
230export async function handleTaskTitleChanged(
231 event: TaskTitleChangedEvent,
232 context: PluginContext,
233): Promise<void> {
234 const config = normalizeSlackConfig(context.config as SlackConfig);
235 if (!isEnabled(config, "taskTitleChanged")) return;
236
237 const data = await getSlackEventData(
238 event.taskId,
239 event.projectId,
240 event.userId,
241 );
242 if (!data) return;
243
244 await sendSlackMessage(
245 config,
246 "Task title changed",
247 `Task renamed from *${truncate(event.oldTitle, 120)}* to *${truncate(event.newTitle, 120)}*.`,
248 data,
249 );
250}
251
252export async function handleTaskDescriptionChanged(
253 event: TaskDescriptionChangedEvent,
254 context: PluginContext,
255): Promise<void> {
256 const config = normalizeSlackConfig(context.config as SlackConfig);
257 if (!isEnabled(config, "taskDescriptionChanged")) return;
258
259 const data = await getSlackEventData(
260 event.taskId,
261 event.projectId,
262 event.userId,
263 );
264 if (!data) return;
265
266 await sendSlackMessage(
267 config,
268 "Task description changed",
269 `The task description was updated${event.newDescription ? `: ${truncate(event.newDescription.replace(/\s+/g, " "), 160)}` : "."}`,
270 data,
271 );
272}
273
274export async function handleTaskCommentCreated(
275 event: TaskCommentCreatedEvent,
276 context: PluginContext,
277): Promise<void> {
278 const config = normalizeSlackConfig(context.config as SlackConfig);
279 if (!isEnabled(config, "taskCommentCreated")) return;
280
281 const data = await getSlackEventData(
282 event.taskId,
283 event.projectId,
284 event.userId,
285 );
286 if (!data) return;
287
288 await sendSlackMessage(
289 config,
290 "New task comment",
291 truncate(event.comment.replace(/\s+/g, " "), 200),
292 data,
293 );
294}