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 cd7cada2f86b4e866a15b4323bb8d6d7ab5bba8b 348 lines 8.1 kB view raw
1import { and, eq } from "drizzle-orm"; 2import db from "../../database"; 3import { 4 integrationTable, 5 projectTable, 6 taskTable, 7 userTable, 8 workspaceTable, 9} from "../../database/schema"; 10import type { 11 PluginContext, 12 TaskCommentCreatedEvent, 13 TaskCreatedEvent, 14 TaskDescriptionChangedEvent, 15 TaskPriorityChangedEvent, 16 TaskStatusChangedEvent, 17 TaskTitleChangedEvent, 18} from "../types"; 19import { postToGenericWebhook } from "./client"; 20import type { GenericWebhookConfig, GenericWebhookEventKey } from "./config"; 21import { normalizeGenericWebhookConfig } from "./config"; 22 23type GenericWebhookTaskData = { 24 id: string; 25 title: string; 26 number: number | null; 27 status: string | null; 28 priority: string | null; 29 projectId: string; 30 projectName: string; 31 workspaceId: string; 32 taskUrl: string; 33}; 34 35function isEnabled( 36 config: GenericWebhookConfig, 37 key: GenericWebhookEventKey, 38): boolean { 39 return config.events?.[key] ?? false; 40} 41 42async function getTaskData( 43 taskId: string, 44 projectId: string, 45): Promise<GenericWebhookTaskData | null> { 46 const [taskRow] = await db 47 .select({ 48 id: taskTable.id, 49 title: taskTable.title, 50 number: taskTable.number, 51 status: taskTable.status, 52 priority: taskTable.priority, 53 projectId: projectTable.id, 54 projectName: projectTable.name, 55 workspaceId: workspaceTable.id, 56 }) 57 .from(taskTable) 58 .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 59 .innerJoin(workspaceTable, eq(projectTable.workspaceId, workspaceTable.id)) 60 .where(and(eq(taskTable.id, taskId), eq(projectTable.id, projectId))) 61 .limit(1); 62 63 if (!taskRow) { 64 return null; 65 } 66 67 const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173"; 68 69 return { 70 ...taskRow, 71 taskUrl: `${clientUrl}/dashboard/workspace/${taskRow.workspaceId}/project/${taskRow.projectId}/task/${taskId}`, 72 }; 73} 74 75async function getActor(userId: string | null): Promise<{ 76 id: string | null; 77 name: string | null; 78}> { 79 if (!userId) { 80 return { 81 id: null, 82 name: null, 83 }; 84 } 85 86 const [user] = await db 87 .select({ id: userTable.id, name: userTable.name }) 88 .from(userTable) 89 .where(eq(userTable.id, userId)) 90 .limit(1); 91 92 return { 93 id: user?.id ?? userId, 94 name: user?.name ?? null, 95 }; 96} 97 98async function persistWebhookHealth( 99 projectId: string, 100 update: (config: GenericWebhookConfig) => GenericWebhookConfig, 101): Promise<void> { 102 try { 103 const integration = await db.query.integrationTable.findFirst({ 104 where: and( 105 eq(integrationTable.projectId, projectId), 106 eq(integrationTable.type, "generic-webhook"), 107 ), 108 }); 109 110 if (!integration) { 111 return; 112 } 113 114 const currentConfig = normalizeGenericWebhookConfig( 115 JSON.parse(integration.config) as GenericWebhookConfig, 116 ); 117 118 await db 119 .update(integrationTable) 120 .set({ 121 config: JSON.stringify(update(currentConfig)), 122 updatedAt: new Date(), 123 }) 124 .where(eq(integrationTable.id, integration.id)); 125 } catch (error) { 126 console.error("persistWebhookHealth failed", { 127 error, 128 projectId, 129 }); 130 } 131} 132 133async function sendEvent( 134 config: GenericWebhookConfig, 135 eventName: string, 136 taskId: string, 137 projectId: string, 138 userId: string | null, 139 data: Record<string, unknown>, 140): Promise<void> { 141 const task = await getTaskData(taskId, projectId); 142 if (!task) return; 143 144 const actor = await getActor(userId); 145 const attempt = { 146 eventName, 147 taskId, 148 projectId, 149 webhookUrl: config.webhookUrl, 150 }; 151 152 try { 153 await postToGenericWebhook( 154 config.webhookUrl, 155 { 156 event: eventName, 157 timestamp: new Date().toISOString(), 158 integration: { 159 type: "generic-webhook", 160 }, 161 project: { 162 id: task.projectId, 163 name: task.projectName, 164 workspaceId: task.workspaceId, 165 }, 166 task: { 167 id: task.id, 168 number: task.number, 169 title: task.title, 170 status: task.status, 171 priority: task.priority, 172 url: task.taskUrl, 173 }, 174 actor, 175 data, 176 }, 177 config.secret, 178 ); 179 180 void persistWebhookHealth(projectId, (currentConfig) => ({ 181 ...currentConfig, 182 health: { 183 ...currentConfig.health, 184 lastSuccessAt: new Date().toISOString(), 185 lastFailureMessage: undefined, 186 lastAttempt: attempt, 187 }, 188 })); 189 } catch (error) { 190 const message = 191 error instanceof Error ? (error.stack ?? error.message) : String(error); 192 193 void persistWebhookHealth(projectId, (currentConfig) => ({ 194 ...currentConfig, 195 health: { 196 ...currentConfig.health, 197 lastFailureAt: new Date().toISOString(), 198 lastFailureMessage: message, 199 failureCount: (currentConfig.health?.failureCount ?? 0) + 1, 200 lastAttempt: attempt, 201 }, 202 })); 203 204 console.error("sendEvent postToGenericWebhook failed", { 205 error, 206 eventName, 207 taskId, 208 projectId, 209 webhookUrl: config.webhookUrl, 210 }); 211 } 212} 213 214export async function handleTaskCreated( 215 event: TaskCreatedEvent, 216 context: PluginContext, 217): Promise<void> { 218 const config = normalizeGenericWebhookConfig( 219 context.config as GenericWebhookConfig, 220 ); 221 if (!isEnabled(config, "taskCreated")) return; 222 223 await sendEvent( 224 config, 225 "task.created", 226 event.taskId, 227 event.projectId, 228 event.userId, 229 { 230 title: event.title, 231 description: event.description, 232 priority: event.priority, 233 status: event.status, 234 number: event.number, 235 }, 236 ); 237} 238 239export async function handleTaskStatusChanged( 240 event: TaskStatusChangedEvent, 241 context: PluginContext, 242): Promise<void> { 243 const config = normalizeGenericWebhookConfig( 244 context.config as GenericWebhookConfig, 245 ); 246 if (!isEnabled(config, "taskStatusChanged")) return; 247 248 await sendEvent( 249 config, 250 "task.status_changed", 251 event.taskId, 252 event.projectId, 253 event.userId, 254 { 255 title: event.title, 256 oldStatus: event.oldStatus, 257 newStatus: event.newStatus, 258 }, 259 ); 260} 261 262export async function handleTaskPriorityChanged( 263 event: TaskPriorityChangedEvent, 264 context: PluginContext, 265): Promise<void> { 266 const config = normalizeGenericWebhookConfig( 267 context.config as GenericWebhookConfig, 268 ); 269 if (!isEnabled(config, "taskPriorityChanged")) return; 270 271 await sendEvent( 272 config, 273 "task.priority_changed", 274 event.taskId, 275 event.projectId, 276 event.userId, 277 { 278 title: event.title, 279 oldPriority: event.oldPriority, 280 newPriority: event.newPriority, 281 }, 282 ); 283} 284 285export async function handleTaskTitleChanged( 286 event: TaskTitleChangedEvent, 287 context: PluginContext, 288): Promise<void> { 289 const config = normalizeGenericWebhookConfig( 290 context.config as GenericWebhookConfig, 291 ); 292 if (!isEnabled(config, "taskTitleChanged")) return; 293 294 await sendEvent( 295 config, 296 "task.title_changed", 297 event.taskId, 298 event.projectId, 299 event.userId, 300 { 301 oldTitle: event.oldTitle, 302 newTitle: event.newTitle, 303 }, 304 ); 305} 306 307export async function handleTaskDescriptionChanged( 308 event: TaskDescriptionChangedEvent, 309 context: PluginContext, 310): Promise<void> { 311 const config = normalizeGenericWebhookConfig( 312 context.config as GenericWebhookConfig, 313 ); 314 if (!isEnabled(config, "taskDescriptionChanged")) return; 315 316 await sendEvent( 317 config, 318 "task.description_changed", 319 event.taskId, 320 event.projectId, 321 event.userId, 322 { 323 oldDescription: event.oldDescription, 324 newDescription: event.newDescription, 325 }, 326 ); 327} 328 329export async function handleTaskCommentCreated( 330 event: TaskCommentCreatedEvent, 331 context: PluginContext, 332): Promise<void> { 333 const config = normalizeGenericWebhookConfig( 334 context.config as GenericWebhookConfig, 335 ); 336 if (!isEnabled(config, "taskCommentCreated")) return; 337 338 await sendEvent( 339 config, 340 "task.comment_created", 341 event.taskId, 342 event.projectId, 343 event.userId, 344 { 345 comment: event.comment, 346 }, 347 ); 348}