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 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}