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 main 530 lines 14 kB view raw
1import { createHmac } from "node:crypto"; 2import { sendNotificationEmail } from "@kaneo/email"; 3import { and, eq } from "drizzle-orm"; 4import db from "../database"; 5import { 6 notificationTable, 7 projectTable, 8 taskTable, 9 userNotificationPreferenceTable, 10 userNotificationWorkspaceRuleTable, 11 userTable, 12 workspaceTable, 13} from "../database/schema"; 14import { assertPublicWebhookDestination } from "../plugins/generic-webhook/config"; 15import { decryptSecret } from "./secrets"; 16 17const DEFAULT_OUTBOUND_FETCH_TIMEOUT_MS = 15_000; 18 19async function fetchWithTimeout( 20 url: string, 21 init: RequestInit & { timeoutMs?: number }, 22): Promise<Response> { 23 const timeoutMs = init.timeoutMs ?? DEFAULT_OUTBOUND_FETCH_TIMEOUT_MS; 24 const { timeoutMs: _timeout, ...rest } = init; 25 const controller = new AbortController(); 26 const timer = setTimeout(() => controller.abort(), timeoutMs); 27 try { 28 return await fetch(url, { ...rest, signal: controller.signal }); 29 } finally { 30 clearTimeout(timer); 31 } 32} 33 34type ResolvedNotificationContext = { 35 workspaceId: string; 36 workspaceName: string; 37 projectId: string | null; 38 projectName: string | null; 39 taskId: string | null; 40 taskTitle: string | null; 41 taskUrl: string | null; 42}; 43 44type DeliveryContent = { 45 title: string; 46 body: string; 47}; 48 49function buildTaskUrl(workspaceId: string, projectId: string, taskId: string) { 50 const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173"; 51 return `${clientUrl}/dashboard/workspace/${workspaceId}/project/${projectId}/task/${taskId}`; 52} 53 54function getStringValue( 55 data: Record<string, unknown> | null | undefined, 56 key: string, 57) { 58 const value = data?.[key]; 59 return typeof value === "string" ? value : null; 60} 61 62function buildDeliveryContent(notification: { 63 type: string; 64 content: string | null; 65 title: string | null; 66 eventData: Record<string, unknown> | null; 67}): DeliveryContent { 68 if (notification.title && notification.content) { 69 return { 70 title: notification.title, 71 body: notification.content, 72 }; 73 } 74 75 switch (notification.type) { 76 case "task_created": { 77 const taskTitle = getStringValue(notification.eventData, "taskTitle"); 78 return { 79 title: "New task created", 80 body: taskTitle 81 ? `A new task was created: ${taskTitle}` 82 : "A new task was created in Kaneo.", 83 }; 84 } 85 case "workspace_created": { 86 const workspaceName = getStringValue( 87 notification.eventData, 88 "workspaceName", 89 ); 90 return { 91 title: "Workspace created", 92 body: workspaceName 93 ? `Workspace created: ${workspaceName}` 94 : "A new workspace was created in Kaneo.", 95 }; 96 } 97 case "task_status_changed": { 98 const taskTitle = getStringValue(notification.eventData, "taskTitle"); 99 const oldStatus = getStringValue(notification.eventData, "oldStatus"); 100 const newStatus = getStringValue(notification.eventData, "newStatus"); 101 return { 102 title: "Task status changed", 103 body: 104 taskTitle && oldStatus && newStatus 105 ? `${taskTitle} moved from ${oldStatus} to ${newStatus}.` 106 : "A task status changed in Kaneo.", 107 }; 108 } 109 case "task_assignee_changed": { 110 const taskTitle = getStringValue(notification.eventData, "taskTitle"); 111 return { 112 title: "Task assigned to you", 113 body: taskTitle 114 ? `You were assigned to ${taskTitle}.` 115 : "A task was assigned to you in Kaneo.", 116 }; 117 } 118 case "time_entry_created": { 119 const taskTitle = getStringValue(notification.eventData, "taskTitle"); 120 return { 121 title: "Time entry created", 122 body: taskTitle 123 ? `A time entry was created for ${taskTitle}.` 124 : "A time entry was created in Kaneo.", 125 }; 126 } 127 case "due_date_reminder": { 128 const taskTitle = getStringValue(notification.eventData, "taskTitle"); 129 const reminderType = getStringValue( 130 notification.eventData, 131 "reminderType", 132 ); 133 const label = 134 reminderType === "one_hour_before" ? "in 1 hour" : "in 1 day"; 135 return { 136 title: "Task due soon", 137 body: taskTitle 138 ? `"${taskTitle}" is due ${label}.` 139 : `A task is due ${label}.`, 140 }; 141 } 142 case "task_overdue": { 143 const taskTitle = getStringValue(notification.eventData, "taskTitle"); 144 return { 145 title: "Task overdue", 146 body: taskTitle 147 ? `"${taskTitle}" is past its due date.` 148 : "A task is past its due date.", 149 }; 150 } 151 default: 152 return { 153 title: notification.title ?? "New Kaneo notification", 154 body: notification.content ?? "You have a new notification in Kaneo.", 155 }; 156 } 157} 158 159async function resolveNotificationContext(notification: { 160 resourceType: string | null; 161 resourceId: string | null; 162}): Promise<ResolvedNotificationContext | null> { 163 if (!notification.resourceType || !notification.resourceId) { 164 return null; 165 } 166 167 if (notification.resourceType === "task") { 168 const [task] = await db 169 .select({ 170 taskId: taskTable.id, 171 taskTitle: taskTable.title, 172 projectId: projectTable.id, 173 projectName: projectTable.name, 174 workspaceId: workspaceTable.id, 175 workspaceName: workspaceTable.name, 176 }) 177 .from(taskTable) 178 .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 179 .innerJoin( 180 workspaceTable, 181 eq(projectTable.workspaceId, workspaceTable.id), 182 ) 183 .where(eq(taskTable.id, notification.resourceId)) 184 .limit(1); 185 186 if (!task) { 187 return null; 188 } 189 190 return { 191 workspaceId: task.workspaceId, 192 workspaceName: task.workspaceName, 193 projectId: task.projectId, 194 projectName: task.projectName, 195 taskId: task.taskId, 196 taskTitle: task.taskTitle, 197 taskUrl: buildTaskUrl(task.workspaceId, task.projectId, task.taskId), 198 }; 199 } 200 201 if (notification.resourceType === "workspace") { 202 const [workspace] = await db 203 .select({ 204 workspaceId: workspaceTable.id, 205 workspaceName: workspaceTable.name, 206 }) 207 .from(workspaceTable) 208 .where(eq(workspaceTable.id, notification.resourceId)) 209 .limit(1); 210 211 if (!workspace) { 212 return null; 213 } 214 215 return { 216 workspaceId: workspace.workspaceId, 217 workspaceName: workspace.workspaceName, 218 projectId: null, 219 projectName: null, 220 taskId: null, 221 taskTitle: null, 222 taskUrl: null, 223 }; 224 } 225 226 return null; 227} 228 229async function sendNtfyNotification(input: { 230 serverUrl: string; 231 topic: string; 232 token?: string | null; 233 title: string; 234 body: string; 235 clickUrl?: string | null; 236}) { 237 await assertPublicWebhookDestination(input.serverUrl); 238 239 const response = await fetchWithTimeout( 240 `${input.serverUrl.replace(/\/+$/, "")}/${encodeURIComponent(input.topic)}`, 241 { 242 method: "POST", 243 headers: { 244 ...(input.token ? { Authorization: `Bearer ${input.token}` } : {}), 245 ...(input.clickUrl ? { Click: input.clickUrl } : {}), 246 Title: input.title, 247 }, 248 body: input.body, 249 }, 250 ); 251 252 if (!response.ok) { 253 throw new Error( 254 `ntfy delivery failed (${response.status}): ${await response.text()}`, 255 ); 256 } 257} 258 259async function sendGotifyNotification(input: { 260 serverUrl: string; 261 token: string; 262 title: string; 263 body: string; 264 clickUrl?: string | null; 265}) { 266 await assertPublicWebhookDestination(input.serverUrl); 267 268 // Gotify expects the app token in the query string; that can surface in logs, proxies, and browser history — factor this into Gotify placement and log handling. 269 const response = await fetchWithTimeout( 270 `${input.serverUrl.replace(/\/+$/, "")}/message?token=${encodeURIComponent( 271 input.token, 272 )}`, 273 { 274 method: "POST", 275 headers: { 276 "Content-Type": "application/json", 277 }, 278 body: JSON.stringify({ 279 title: input.title, 280 message: input.body, 281 priority: 5, 282 extras: input.clickUrl 283 ? { 284 "client::notification": { 285 click: { 286 url: input.clickUrl, 287 }, 288 }, 289 "client::display": { 290 contentType: "text/plain", 291 }, 292 } 293 : undefined, 294 }), 295 }, 296 ); 297 298 if (!response.ok) { 299 throw new Error( 300 `Gotify delivery failed (${response.status}): ${await response.text()}`, 301 ); 302 } 303} 304 305async function sendWebhookNotification(input: { 306 webhookUrl: string; 307 secret?: string | null; 308 payload: Record<string, unknown>; 309}) { 310 await assertPublicWebhookDestination(input.webhookUrl); 311 312 const body = JSON.stringify(input.payload); 313 const headers: Record<string, string> = { 314 "Content-Type": "application/json", 315 }; 316 317 if (input.secret) { 318 headers["X-Kaneo-Signature"] = createHmac("sha256", input.secret) 319 .update(body) 320 .digest("hex"); 321 } 322 323 const response = await fetchWithTimeout(input.webhookUrl, { 324 method: "POST", 325 headers, 326 body, 327 }); 328 329 if (!response.ok) { 330 throw new Error( 331 `Webhook delivery failed (${response.status}): ${await response.text()}`, 332 ); 333 } 334} 335 336export async function deliverNotification( 337 notificationId: string, 338): Promise<void> { 339 const notification = await db.query.notificationTable.findFirst({ 340 where: eq(notificationTable.id, notificationId), 341 }); 342 343 if (!notification) { 344 return; 345 } 346 347 const context = await resolveNotificationContext(notification); 348 if (!context) { 349 console.info("Notification delivery skipped: unresolved context", { 350 notificationId, 351 notificationTableId: notification.id, 352 resourceType: notification.resourceType, 353 resourceId: notification.resourceId, 354 reason: 355 "resolveNotificationContext returned null (missing resource, deleted task, or unsupported resource type)", 356 }); 357 return; 358 } 359 360 const [user] = await db 361 .select({ 362 email: userTable.email, 363 name: userTable.name, 364 locale: userTable.locale, 365 }) 366 .from(userTable) 367 .where(eq(userTable.id, notification.userId)) 368 .limit(1); 369 370 if (!user) { 371 return; 372 } 373 374 const preference = await db.query.userNotificationPreferenceTable.findFirst({ 375 where: eq(userNotificationPreferenceTable.userId, notification.userId), 376 }); 377 378 if (!preference) { 379 return; 380 } 381 382 const decryptedPreference = { 383 ...preference, 384 ntfyToken: decryptSecret(preference.ntfyToken), 385 gotifyToken: decryptSecret(preference.gotifyToken), 386 webhookSecret: decryptSecret(preference.webhookSecret), 387 }; 388 389 const rule = await db.query.userNotificationWorkspaceRuleTable.findFirst({ 390 where: and( 391 eq(userNotificationWorkspaceRuleTable.userId, notification.userId), 392 eq(userNotificationWorkspaceRuleTable.workspaceId, context.workspaceId), 393 ), 394 with: { 395 selectedProjects: true, 396 }, 397 }); 398 399 if (!rule?.isActive) { 400 return; 401 } 402 403 if ( 404 rule.projectMode === "selected" && 405 (!context.projectId || 406 !rule.selectedProjects.some( 407 (project) => project.projectId === context.projectId, 408 )) 409 ) { 410 return; 411 } 412 413 const content = buildDeliveryContent({ 414 type: notification.type, 415 title: notification.title ?? null, 416 content: notification.content ?? null, 417 eventData: 418 notification.eventData && typeof notification.eventData === "object" 419 ? (notification.eventData as Record<string, unknown>) 420 : null, 421 }); 422 423 const webhookPayload = { 424 notification: { 425 id: notification.id, 426 type: notification.type, 427 title: content.title, 428 content: content.body, 429 createdAt: notification.createdAt, 430 eventData: notification.eventData, 431 resourceId: notification.resourceId, 432 resourceType: notification.resourceType, 433 }, 434 workspace: { 435 id: context.workspaceId, 436 name: context.workspaceName, 437 }, 438 project: context.projectId 439 ? { 440 id: context.projectId, 441 name: context.projectName, 442 } 443 : null, 444 task: context.taskId 445 ? { 446 id: context.taskId, 447 title: context.taskTitle, 448 url: context.taskUrl, 449 } 450 : null, 451 user: { 452 id: notification.userId, 453 email: user.email, 454 name: user.name, 455 }, 456 }; 457 458 const deliveries: Array<Promise<void>> = []; 459 460 if (decryptedPreference.emailEnabled && rule.emailEnabled && user.email) { 461 deliveries.push( 462 sendNotificationEmail(user.email, content.title, { 463 title: content.title, 464 message: content.body, 465 actionUrl: context.taskUrl, 466 actionLabel: context.taskUrl ? "Open in Kaneo" : undefined, 467 locale: user.locale ?? null, 468 }), 469 ); 470 } 471 472 if ( 473 decryptedPreference.ntfyEnabled && 474 decryptedPreference.ntfyServerUrl && 475 decryptedPreference.ntfyTopic && 476 rule.ntfyEnabled 477 ) { 478 deliveries.push( 479 sendNtfyNotification({ 480 serverUrl: decryptedPreference.ntfyServerUrl, 481 topic: decryptedPreference.ntfyTopic, 482 token: decryptedPreference.ntfyToken, 483 title: content.title, 484 body: content.body, 485 clickUrl: context.taskUrl, 486 }), 487 ); 488 } 489 490 if ( 491 decryptedPreference.gotifyEnabled && 492 decryptedPreference.gotifyServerUrl && 493 decryptedPreference.gotifyToken && 494 rule.gotifyEnabled 495 ) { 496 deliveries.push( 497 sendGotifyNotification({ 498 serverUrl: decryptedPreference.gotifyServerUrl, 499 token: decryptedPreference.gotifyToken, 500 title: content.title, 501 body: content.body, 502 clickUrl: context.taskUrl, 503 }), 504 ); 505 } 506 507 if ( 508 decryptedPreference.webhookEnabled && 509 decryptedPreference.webhookUrl && 510 rule.webhookEnabled 511 ) { 512 deliveries.push( 513 sendWebhookNotification({ 514 webhookUrl: decryptedPreference.webhookUrl, 515 secret: decryptedPreference.webhookSecret, 516 payload: webhookPayload, 517 }), 518 ); 519 } 520 521 const results = await Promise.allSettled(deliveries); 522 for (const result of results) { 523 if (result.status === "rejected") { 524 console.error("Notification delivery failed", { 525 notificationId, 526 error: result.reason, 527 }); 528 } 529 } 530}