kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 9a620ba2f31238f03cd28f1da5ef3838d67e4e8a 294 lines 7.6 kB view raw
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("&", "&amp;") 39 .replaceAll("<", "&lt;") 40 .replaceAll(">", "&gt;"); 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}