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 624 lines 18 kB view raw
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}