kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
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}