kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { and, eq, inArray, or } from "drizzle-orm";
2import { HTTPException } from "hono/http-exception";
3import db from "../database";
4import {
5 projectTable,
6 userNotificationPreferenceTable,
7 userNotificationWorkspaceProjectTable,
8 userNotificationWorkspaceRuleTable,
9 workspaceUserTable,
10} from "../database/schema";
11import { assertPublicWebhookDestination } from "../plugins/generic-webhook/config";
12import { decryptSecret, encryptSecret } from "./secrets";
13
14export type NotificationPreferenceProjectMode = "all" | "selected";
15
16export type NotificationPreferenceResponse = {
17 emailAddress: string | null;
18 emailEnabled: boolean;
19 ntfyEnabled: boolean;
20 ntfyConfigured: boolean;
21 ntfyServerUrl: string | null;
22 ntfyTopic: string | null;
23 ntfyTokenConfigured: boolean;
24 maskedNtfyToken: string | null;
25 gotifyEnabled: boolean;
26 gotifyConfigured: boolean;
27 gotifyServerUrl: string | null;
28 gotifyTokenConfigured: boolean;
29 maskedGotifyToken: string | null;
30 webhookEnabled: boolean;
31 webhookConfigured: boolean;
32 webhookUrl: string | null;
33 webhookSecretConfigured: boolean;
34 maskedWebhookSecret: string | null;
35 workspaces: Array<{
36 id: string;
37 workspaceId: string;
38 workspaceName: string;
39 isActive: boolean;
40 emailEnabled: boolean;
41 ntfyEnabled: boolean;
42 gotifyEnabled: boolean;
43 webhookEnabled: boolean;
44 projectMode: NotificationPreferenceProjectMode;
45 selectedProjectIds: string[];
46 createdAt: Date;
47 updatedAt: Date;
48 }>;
49 createdAt: Date | null;
50 updatedAt: Date | null;
51};
52
53export type UpdateNotificationPreferenceInput = {
54 emailEnabled?: boolean;
55 ntfyEnabled?: boolean;
56 ntfyServerUrl?: string | null;
57 ntfyTopic?: string | null;
58 ntfyToken?: string | null;
59 gotifyEnabled?: boolean;
60 gotifyServerUrl?: string | null;
61 gotifyToken?: string | null;
62 webhookEnabled?: boolean;
63 webhookUrl?: string | null;
64 webhookSecret?: string | null;
65};
66
67export type UpsertWorkspaceRuleInput = {
68 isActive: boolean;
69 emailEnabled: boolean;
70 ntfyEnabled: boolean;
71 gotifyEnabled: boolean;
72 webhookEnabled: boolean;
73 projectMode: NotificationPreferenceProjectMode;
74 selectedProjectIds?: string[];
75};
76
77type WorkspaceRuleChannelState = {
78 emailEnabled: boolean;
79 ntfyEnabled: boolean;
80 gotifyEnabled: boolean;
81 webhookEnabled: boolean;
82};
83
84function normalizeOptionalString(value: string | null | undefined) {
85 if (typeof value !== "string") {
86 return value === null ? null : undefined;
87 }
88
89 const trimmed = value.trim();
90 return trimmed.length > 0 ? trimmed : null;
91}
92
93function maskValue(value: string | undefined | null): string | null {
94 if (!value) return null;
95 return value.length > 8 ? `${value.slice(0, 4)}…${value.slice(-4)}` : "••••";
96}
97
98function normalizeSecretInput(
99 inputValue: string | null | undefined,
100 existingValue: string | null | undefined,
101) {
102 if (inputValue === undefined) {
103 return normalizeOptionalString(existingValue ?? undefined);
104 }
105
106 return normalizeOptionalString(inputValue);
107}
108
109async function assertWorkspaceMembership(userId: string, workspaceId: string) {
110 const [membership] = await db
111 .select({ workspaceId: workspaceUserTable.workspaceId })
112 .from(workspaceUserTable)
113 .where(
114 and(
115 eq(workspaceUserTable.userId, userId),
116 eq(workspaceUserTable.workspaceId, workspaceId),
117 ),
118 )
119 .limit(1);
120
121 if (!membership) {
122 throw new HTTPException(403, {
123 message: "You don't have access to this workspace",
124 });
125 }
126}
127
128export async function validateProjectSelection(
129 workspaceId: string,
130 selectedProjectIds: string[],
131) {
132 if (selectedProjectIds.length === 0) {
133 throw new HTTPException(400, {
134 message: "Select at least one project for selected project mode",
135 });
136 }
137
138 const projects = await db
139 .select({ id: projectTable.id })
140 .from(projectTable)
141 .where(
142 and(
143 eq(projectTable.workspaceId, workspaceId),
144 inArray(projectTable.id, selectedProjectIds),
145 ),
146 );
147
148 if (projects.length !== selectedProjectIds.length) {
149 throw new HTTPException(400, {
150 message: "One or more selected projects are invalid",
151 });
152 }
153}
154
155export async function getNotificationPreferences(
156 userId: string,
157 emailAddress: string | null,
158): Promise<NotificationPreferenceResponse> {
159 const preference = await db.query.userNotificationPreferenceTable.findFirst({
160 where: eq(userNotificationPreferenceTable.userId, userId),
161 });
162
163 const decryptedPreference = preference
164 ? {
165 ...preference,
166 ntfyToken: decryptSecret(preference.ntfyToken),
167 gotifyToken: decryptSecret(preference.gotifyToken),
168 webhookSecret: decryptSecret(preference.webhookSecret),
169 }
170 : null;
171
172 const rules = await db.query.userNotificationWorkspaceRuleTable.findMany({
173 where: eq(userNotificationWorkspaceRuleTable.userId, userId),
174 with: {
175 workspace: true,
176 selectedProjects: true,
177 },
178 orderBy: (table, { asc }) => [asc(table.createdAt)],
179 });
180
181 return {
182 emailAddress,
183 emailEnabled: decryptedPreference?.emailEnabled ?? false,
184 ntfyEnabled: decryptedPreference?.ntfyEnabled ?? false,
185 ntfyConfigured: Boolean(
186 decryptedPreference?.ntfyServerUrl && decryptedPreference?.ntfyTopic,
187 ),
188 ntfyServerUrl: decryptedPreference?.ntfyServerUrl ?? null,
189 ntfyTopic: decryptedPreference?.ntfyTopic ?? null,
190 ntfyTokenConfigured: Boolean(decryptedPreference?.ntfyToken),
191 maskedNtfyToken: maskValue(decryptedPreference?.ntfyToken),
192 gotifyEnabled: decryptedPreference?.gotifyEnabled ?? false,
193 gotifyConfigured: Boolean(
194 decryptedPreference?.gotifyServerUrl && decryptedPreference?.gotifyToken,
195 ),
196 gotifyServerUrl: decryptedPreference?.gotifyServerUrl ?? null,
197 gotifyTokenConfigured: Boolean(decryptedPreference?.gotifyToken),
198 maskedGotifyToken: maskValue(decryptedPreference?.gotifyToken),
199 webhookEnabled: decryptedPreference?.webhookEnabled ?? false,
200 webhookConfigured: Boolean(decryptedPreference?.webhookUrl),
201 webhookUrl: decryptedPreference?.webhookUrl ?? null,
202 webhookSecretConfigured: Boolean(decryptedPreference?.webhookSecret),
203 maskedWebhookSecret: maskValue(decryptedPreference?.webhookSecret),
204 workspaces: rules.map((rule) => ({
205 id: rule.id,
206 workspaceId: rule.workspaceId,
207 workspaceName: rule.workspace.name,
208 isActive: rule.isActive ?? true,
209 emailEnabled: rule.emailEnabled ?? false,
210 ntfyEnabled: rule.ntfyEnabled ?? false,
211 gotifyEnabled: rule.gotifyEnabled ?? false,
212 webhookEnabled: rule.webhookEnabled ?? false,
213 projectMode:
214 rule.projectMode === "selected" ? "selected" : ("all" as const),
215 selectedProjectIds: rule.selectedProjects.map(
216 (project) => project.projectId,
217 ),
218 createdAt: rule.createdAt,
219 updatedAt: rule.updatedAt,
220 })),
221 createdAt: preference?.createdAt ?? null,
222 updatedAt: preference?.updatedAt ?? null,
223 };
224}
225
226export async function updateNotificationPreferences(
227 userId: string,
228 emailAddress: string | null,
229 input: UpdateNotificationPreferenceInput,
230): Promise<NotificationPreferenceResponse> {
231 const existing = await db.query.userNotificationPreferenceTable.findFirst({
232 where: eq(userNotificationPreferenceTable.userId, userId),
233 });
234
235 const decryptedExisting = existing
236 ? {
237 ...existing,
238 ntfyToken: decryptSecret(existing.ntfyToken),
239 gotifyToken: decryptSecret(existing.gotifyToken),
240 webhookSecret: decryptSecret(existing.webhookSecret),
241 }
242 : null;
243
244 const ntfyServerUrl = normalizeOptionalString(
245 input.ntfyServerUrl ?? decryptedExisting?.ntfyServerUrl,
246 );
247 const ntfyTopic = normalizeOptionalString(
248 input.ntfyTopic ?? decryptedExisting?.ntfyTopic,
249 );
250 const ntfyToken = normalizeSecretInput(
251 input.ntfyToken,
252 decryptedExisting?.ntfyToken,
253 );
254 const gotifyServerUrl = normalizeOptionalString(
255 input.gotifyServerUrl ?? decryptedExisting?.gotifyServerUrl,
256 );
257 const gotifyToken = normalizeSecretInput(
258 input.gotifyToken,
259 decryptedExisting?.gotifyToken,
260 );
261 const webhookUrl = normalizeOptionalString(
262 input.webhookUrl ?? decryptedExisting?.webhookUrl,
263 );
264 const webhookSecret = normalizeSecretInput(
265 input.webhookSecret,
266 decryptedExisting?.webhookSecret,
267 );
268
269 const emailEnabled =
270 input.emailEnabled ?? decryptedExisting?.emailEnabled ?? false;
271 const ntfyEnabled =
272 input.ntfyEnabled ?? decryptedExisting?.ntfyEnabled ?? false;
273 const gotifyEnabled =
274 input.gotifyEnabled ?? decryptedExisting?.gotifyEnabled ?? false;
275 const webhookEnabled =
276 input.webhookEnabled ?? decryptedExisting?.webhookEnabled ?? false;
277
278 const enabledRuleCascade: WorkspaceRuleChannelState = {
279 emailEnabled: false,
280 ntfyEnabled: false,
281 gotifyEnabled: false,
282 webhookEnabled: false,
283 };
284
285 const shouldValidateNtfy =
286 ntfyEnabled ||
287 input.ntfyServerUrl !== undefined ||
288 input.ntfyTopic !== undefined ||
289 input.ntfyToken !== undefined;
290
291 const shouldValidateGotify =
292 gotifyEnabled ||
293 input.gotifyServerUrl !== undefined ||
294 input.gotifyToken !== undefined;
295
296 const shouldValidateWebhook =
297 webhookEnabled ||
298 input.webhookUrl !== undefined ||
299 input.webhookSecret !== undefined;
300
301 if (emailEnabled && !emailAddress) {
302 throw new HTTPException(400, {
303 message: "Email notifications require an account email address",
304 });
305 }
306
307 if (shouldValidateNtfy) {
308 if (!ntfyServerUrl || !ntfyTopic) {
309 throw new HTTPException(400, {
310 message: "ntfy requires a server URL and topic",
311 });
312 }
313
314 try {
315 new URL(ntfyServerUrl);
316 await assertPublicWebhookDestination(ntfyServerUrl);
317 } catch (error) {
318 throw new HTTPException(400, {
319 message:
320 error instanceof Error ? error.message : "Invalid ntfy server URL",
321 });
322 }
323 }
324
325 if (shouldValidateGotify) {
326 if (!gotifyServerUrl || !gotifyToken) {
327 throw new HTTPException(400, {
328 message: "Gotify requires a server URL and app token",
329 });
330 }
331
332 try {
333 new URL(gotifyServerUrl);
334 await assertPublicWebhookDestination(gotifyServerUrl);
335 } catch (error) {
336 throw new HTTPException(400, {
337 message:
338 error instanceof Error ? error.message : "Invalid Gotify server URL",
339 });
340 }
341 }
342
343 if (shouldValidateWebhook) {
344 if (!webhookUrl) {
345 throw new HTTPException(400, {
346 message: "Webhook notifications require an endpoint URL",
347 });
348 }
349
350 try {
351 new URL(webhookUrl);
352 await assertPublicWebhookDestination(webhookUrl);
353 } catch (error) {
354 throw new HTTPException(400, {
355 message: error instanceof Error ? error.message : "Invalid webhook URL",
356 });
357 }
358 }
359
360 const data = {
361 userId,
362 emailEnabled,
363 ntfyEnabled,
364 ntfyServerUrl,
365 ntfyTopic,
366 ntfyToken:
367 input.ntfyToken === undefined
368 ? (existing?.ntfyToken ?? null)
369 : (encryptSecret(ntfyToken) ?? null),
370 gotifyEnabled,
371 gotifyServerUrl,
372 gotifyToken:
373 input.gotifyToken === undefined
374 ? (existing?.gotifyToken ?? null)
375 : (encryptSecret(gotifyToken) ?? null),
376 webhookEnabled,
377 webhookUrl,
378 webhookSecret:
379 input.webhookSecret === undefined
380 ? (existing?.webhookSecret ?? null)
381 : (encryptSecret(webhookSecret) ?? null),
382 };
383
384 if (existing) {
385 await db
386 .update(userNotificationPreferenceTable)
387 .set({
388 ...data,
389 updatedAt: new Date(),
390 })
391 .where(eq(userNotificationPreferenceTable.userId, userId));
392 } else {
393 await db.insert(userNotificationPreferenceTable).values(data);
394 }
395
396 const ruleCascade: {
397 emailEnabled?: boolean;
398 ntfyEnabled?: boolean;
399 gotifyEnabled?: boolean;
400 webhookEnabled?: boolean;
401 } = {};
402
403 const hadEmailEnabled = decryptedExisting?.emailEnabled ?? false;
404 const hadNtfyEnabled = decryptedExisting?.ntfyEnabled ?? false;
405 const hadGotifyEnabled = decryptedExisting?.gotifyEnabled ?? false;
406 const hadWebhookEnabled = decryptedExisting?.webhookEnabled ?? false;
407
408 if (!emailEnabled) {
409 ruleCascade.emailEnabled = false;
410 }
411
412 if (!ntfyEnabled || !ntfyServerUrl || !ntfyTopic) {
413 ruleCascade.ntfyEnabled = false;
414 }
415
416 if (!gotifyEnabled || !gotifyServerUrl || !data.gotifyToken) {
417 ruleCascade.gotifyEnabled = false;
418 }
419
420 if (!webhookEnabled || !webhookUrl) {
421 ruleCascade.webhookEnabled = false;
422 }
423
424 if (emailEnabled && !hadEmailEnabled && emailAddress) {
425 enabledRuleCascade.emailEnabled = true;
426 }
427
428 if (ntfyEnabled && !hadNtfyEnabled && ntfyServerUrl && ntfyTopic) {
429 enabledRuleCascade.ntfyEnabled = true;
430 }
431
432 if (
433 gotifyEnabled &&
434 !hadGotifyEnabled &&
435 gotifyServerUrl &&
436 data.gotifyToken
437 ) {
438 enabledRuleCascade.gotifyEnabled = true;
439 }
440
441 if (webhookEnabled && !hadWebhookEnabled && webhookUrl) {
442 enabledRuleCascade.webhookEnabled = true;
443 }
444
445 const ruleEnableCascade = Object.fromEntries(
446 Object.entries(enabledRuleCascade).filter(([, value]) => value),
447 ) as Partial<WorkspaceRuleChannelState>;
448
449 if (
450 Object.keys(ruleCascade).length > 0 ||
451 Object.keys(ruleEnableCascade).length > 0
452 ) {
453 await db
454 .update(userNotificationWorkspaceRuleTable)
455 .set({
456 ...ruleEnableCascade,
457 ...ruleCascade,
458 updatedAt: new Date(),
459 })
460 .where(
461 and(
462 eq(userNotificationWorkspaceRuleTable.userId, userId),
463 eq(userNotificationWorkspaceRuleTable.isActive, true),
464 or(
465 eq(userNotificationWorkspaceRuleTable.emailEnabled, true),
466 eq(userNotificationWorkspaceRuleTable.ntfyEnabled, true),
467 eq(userNotificationWorkspaceRuleTable.gotifyEnabled, true),
468 eq(userNotificationWorkspaceRuleTable.webhookEnabled, true),
469 ),
470 ),
471 );
472 }
473
474 return getNotificationPreferences(userId, emailAddress);
475}
476
477export async function upsertWorkspaceRule(
478 userId: string,
479 workspaceId: string,
480 emailAddress: string | null,
481 input: UpsertWorkspaceRuleInput,
482): Promise<NotificationPreferenceResponse> {
483 await assertWorkspaceMembership(userId, workspaceId);
484
485 if (input.projectMode === "selected") {
486 await validateProjectSelection(workspaceId, input.selectedProjectIds ?? []);
487 }
488
489 const preference = await db.query.userNotificationPreferenceTable.findFirst({
490 where: eq(userNotificationPreferenceTable.userId, userId),
491 });
492
493 if (input.emailEnabled && (!preference?.emailEnabled || !emailAddress)) {
494 throw new HTTPException(400, {
495 message: "Enable email notifications globally before using them here",
496 });
497 }
498
499 if (
500 input.ntfyEnabled &&
501 (!preference?.ntfyEnabled ||
502 !preference.ntfyServerUrl ||
503 !preference.ntfyTopic)
504 ) {
505 throw new HTTPException(400, {
506 message: "Enable ntfy notifications globally before using them here",
507 });
508 }
509
510 if (
511 input.webhookEnabled &&
512 (!preference?.webhookEnabled || !preference.webhookUrl)
513 ) {
514 throw new HTTPException(400, {
515 message: "Enable webhook notifications globally before using them here",
516 });
517 }
518
519 if (
520 input.gotifyEnabled &&
521 (!preference?.gotifyEnabled ||
522 !preference.gotifyServerUrl ||
523 !preference.gotifyToken)
524 ) {
525 throw new HTTPException(400, {
526 message: "Enable Gotify notifications globally before using them here",
527 });
528 }
529
530 const existing = await db.query.userNotificationWorkspaceRuleTable.findFirst({
531 where: and(
532 eq(userNotificationWorkspaceRuleTable.userId, userId),
533 eq(userNotificationWorkspaceRuleTable.workspaceId, workspaceId),
534 ),
535 });
536
537 let ruleId = existing?.id;
538
539 if (existing) {
540 await db
541 .update(userNotificationWorkspaceRuleTable)
542 .set({
543 isActive: input.isActive,
544 emailEnabled: input.emailEnabled,
545 ntfyEnabled: input.ntfyEnabled,
546 gotifyEnabled: input.gotifyEnabled,
547 webhookEnabled: input.webhookEnabled,
548 projectMode: input.projectMode,
549 updatedAt: new Date(),
550 })
551 .where(eq(userNotificationWorkspaceRuleTable.id, existing.id));
552 } else {
553 const [createdRule] = await db
554 .insert(userNotificationWorkspaceRuleTable)
555 .values({
556 userId,
557 workspaceId,
558 isActive: input.isActive,
559 emailEnabled: input.emailEnabled,
560 ntfyEnabled: input.ntfyEnabled,
561 gotifyEnabled: input.gotifyEnabled,
562 webhookEnabled: input.webhookEnabled,
563 projectMode: input.projectMode,
564 })
565 .returning({ id: userNotificationWorkspaceRuleTable.id });
566 ruleId = createdRule?.id;
567 }
568
569 if (!ruleId) {
570 throw new HTTPException(500, {
571 message: "Failed to save notification workspace rule",
572 });
573 }
574
575 const workspaceRuleId = ruleId;
576
577 await db
578 .delete(userNotificationWorkspaceProjectTable)
579 .where(
580 eq(
581 userNotificationWorkspaceProjectTable.workspaceRuleId,
582 workspaceRuleId,
583 ),
584 );
585
586 if (input.projectMode === "selected") {
587 await db.insert(userNotificationWorkspaceProjectTable).values(
588 (input.selectedProjectIds ?? []).map((projectId) => ({
589 workspaceId,
590 workspaceRuleId,
591 projectId,
592 })),
593 );
594 }
595
596 return getNotificationPreferences(userId, emailAddress);
597}
598
599export async function deleteWorkspaceRule(
600 userId: string,
601 workspaceId: string,
602 emailAddress: string | null,
603): Promise<NotificationPreferenceResponse> {
604 await assertWorkspaceMembership(userId, workspaceId);
605
606 const existing = await db.query.userNotificationWorkspaceRuleTable.findFirst({
607 where: and(
608 eq(userNotificationWorkspaceRuleTable.userId, userId),
609 eq(userNotificationWorkspaceRuleTable.workspaceId, workspaceId),
610 ),
611 });
612
613 if (!existing) {
614 throw new HTTPException(404, {
615 message: "Workspace notification rule not found",
616 });
617 }
618
619 await db
620 .delete(userNotificationWorkspaceRuleTable)
621 .where(eq(userNotificationWorkspaceRuleTable.id, existing.id));
622
623 return getNotificationPreferences(userId, emailAddress);
624}