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.

feat: add account notification delivery settings

Tin 4ed1d98a 17de4b3c

+6427 -2
+45
apps/api/drizzle/0020_careful_shiver_man.sql
··· 1 + CREATE TABLE "user_notification_preference" ( 2 + "user_id" text PRIMARY KEY NOT NULL, 3 + "email_enabled" boolean DEFAULT false NOT NULL, 4 + "ntfy_enabled" boolean DEFAULT false NOT NULL, 5 + "ntfy_server_url" text, 6 + "ntfy_topic" text, 7 + "ntfy_token" text, 8 + "webhook_enabled" boolean DEFAULT false NOT NULL, 9 + "webhook_url" text, 10 + "webhook_secret" text, 11 + "created_at" timestamp DEFAULT now() NOT NULL, 12 + "updated_at" timestamp DEFAULT now() NOT NULL 13 + ); 14 + --> statement-breakpoint 15 + CREATE TABLE "user_notification_workspace_project" ( 16 + "id" text PRIMARY KEY NOT NULL, 17 + "workspace_rule_id" text NOT NULL, 18 + "project_id" text NOT NULL, 19 + "created_at" timestamp DEFAULT now() NOT NULL, 20 + CONSTRAINT "user_notification_workspace_project_rule_project_unique" UNIQUE("workspace_rule_id","project_id") 21 + ); 22 + --> statement-breakpoint 23 + CREATE TABLE "user_notification_workspace_rule" ( 24 + "id" text PRIMARY KEY NOT NULL, 25 + "user_id" text NOT NULL, 26 + "workspace_id" text NOT NULL, 27 + "is_active" boolean DEFAULT true NOT NULL, 28 + "email_enabled" boolean DEFAULT false NOT NULL, 29 + "ntfy_enabled" boolean DEFAULT false NOT NULL, 30 + "webhook_enabled" boolean DEFAULT false NOT NULL, 31 + "project_mode" text DEFAULT 'all' NOT NULL, 32 + "created_at" timestamp DEFAULT now() NOT NULL, 33 + "updated_at" timestamp DEFAULT now() NOT NULL, 34 + CONSTRAINT "user_notification_workspace_rule_user_workspace_unique" UNIQUE("user_id","workspace_id") 35 + ); 36 + --> statement-breakpoint 37 + ALTER TABLE "user_notification_preference" ADD CONSTRAINT "user_notification_preference_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 38 + ALTER TABLE "user_notification_workspace_project" ADD CONSTRAINT "user_notification_workspace_project_workspace_rule_id_user_notification_workspace_rule_id_fk" FOREIGN KEY ("workspace_rule_id") REFERENCES "public"."user_notification_workspace_rule"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 39 + ALTER TABLE "user_notification_workspace_project" ADD CONSTRAINT "user_notification_workspace_project_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 40 + ALTER TABLE "user_notification_workspace_rule" ADD CONSTRAINT "user_notification_workspace_rule_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 41 + ALTER TABLE "user_notification_workspace_rule" ADD CONSTRAINT "user_notification_workspace_rule_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint 42 + CREATE INDEX "user_notification_workspace_project_ruleId_idx" ON "user_notification_workspace_project" USING btree ("workspace_rule_id");--> statement-breakpoint 43 + CREATE INDEX "user_notification_workspace_project_projectId_idx" ON "user_notification_workspace_project" USING btree ("project_id");--> statement-breakpoint 44 + CREATE INDEX "user_notification_workspace_rule_userId_idx" ON "user_notification_workspace_rule" USING btree ("user_id");--> statement-breakpoint 45 + CREATE INDEX "user_notification_workspace_rule_workspaceId_idx" ON "user_notification_workspace_rule" USING btree ("workspace_id");
+2854
apps/api/drizzle/meta/0020_snapshot.json
··· 1 + { 2 + "id": "745512c9-3ace-4437-abaf-697e303b6d2a", 3 + "prevId": "355507ff-3b8c-46d7-b93a-6ef124d03692", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.account": { 8 + "name": "account", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "text", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "account_id": { 18 + "name": "account_id", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "provider_id": { 24 + "name": "provider_id", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "user_id": { 30 + "name": "user_id", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "access_token": { 36 + "name": "access_token", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": false 40 + }, 41 + "refresh_token": { 42 + "name": "refresh_token", 43 + "type": "text", 44 + "primaryKey": false, 45 + "notNull": false 46 + }, 47 + "id_token": { 48 + "name": "id_token", 49 + "type": "text", 50 + "primaryKey": false, 51 + "notNull": false 52 + }, 53 + "access_token_expires_at": { 54 + "name": "access_token_expires_at", 55 + "type": "timestamp", 56 + "primaryKey": false, 57 + "notNull": false 58 + }, 59 + "refresh_token_expires_at": { 60 + "name": "refresh_token_expires_at", 61 + "type": "timestamp", 62 + "primaryKey": false, 63 + "notNull": false 64 + }, 65 + "scope": { 66 + "name": "scope", 67 + "type": "text", 68 + "primaryKey": false, 69 + "notNull": false 70 + }, 71 + "password": { 72 + "name": "password", 73 + "type": "text", 74 + "primaryKey": false, 75 + "notNull": false 76 + }, 77 + "created_at": { 78 + "name": "created_at", 79 + "type": "timestamp", 80 + "primaryKey": false, 81 + "notNull": true, 82 + "default": "now()" 83 + }, 84 + "updated_at": { 85 + "name": "updated_at", 86 + "type": "timestamp", 87 + "primaryKey": false, 88 + "notNull": true 89 + } 90 + }, 91 + "indexes": { 92 + "account_userId_idx": { 93 + "name": "account_userId_idx", 94 + "columns": [ 95 + { 96 + "expression": "user_id", 97 + "isExpression": false, 98 + "asc": true, 99 + "nulls": "last" 100 + } 101 + ], 102 + "isUnique": false, 103 + "concurrently": false, 104 + "method": "btree", 105 + "with": {} 106 + } 107 + }, 108 + "foreignKeys": { 109 + "account_user_id_user_id_fk": { 110 + "name": "account_user_id_user_id_fk", 111 + "tableFrom": "account", 112 + "tableTo": "user", 113 + "columnsFrom": ["user_id"], 114 + "columnsTo": ["id"], 115 + "onDelete": "cascade", 116 + "onUpdate": "no action" 117 + } 118 + }, 119 + "compositePrimaryKeys": {}, 120 + "uniqueConstraints": {}, 121 + "policies": {}, 122 + "checkConstraints": {}, 123 + "isRLSEnabled": false 124 + }, 125 + "public.activity": { 126 + "name": "activity", 127 + "schema": "", 128 + "columns": { 129 + "id": { 130 + "name": "id", 131 + "type": "text", 132 + "primaryKey": true, 133 + "notNull": true 134 + }, 135 + "task_id": { 136 + "name": "task_id", 137 + "type": "text", 138 + "primaryKey": false, 139 + "notNull": true 140 + }, 141 + "type": { 142 + "name": "type", 143 + "type": "text", 144 + "primaryKey": false, 145 + "notNull": true 146 + }, 147 + "created_at": { 148 + "name": "created_at", 149 + "type": "timestamp", 150 + "primaryKey": false, 151 + "notNull": true, 152 + "default": "now()" 153 + }, 154 + "user_id": { 155 + "name": "user_id", 156 + "type": "text", 157 + "primaryKey": false, 158 + "notNull": false 159 + }, 160 + "content": { 161 + "name": "content", 162 + "type": "text", 163 + "primaryKey": false, 164 + "notNull": false 165 + }, 166 + "event_data": { 167 + "name": "event_data", 168 + "type": "jsonb", 169 + "primaryKey": false, 170 + "notNull": false 171 + }, 172 + "external_user_name": { 173 + "name": "external_user_name", 174 + "type": "text", 175 + "primaryKey": false, 176 + "notNull": false 177 + }, 178 + "external_user_avatar": { 179 + "name": "external_user_avatar", 180 + "type": "text", 181 + "primaryKey": false, 182 + "notNull": false 183 + }, 184 + "external_source": { 185 + "name": "external_source", 186 + "type": "text", 187 + "primaryKey": false, 188 + "notNull": false 189 + }, 190 + "external_url": { 191 + "name": "external_url", 192 + "type": "text", 193 + "primaryKey": false, 194 + "notNull": false 195 + } 196 + }, 197 + "indexes": {}, 198 + "foreignKeys": { 199 + "activity_task_id_task_id_fk": { 200 + "name": "activity_task_id_task_id_fk", 201 + "tableFrom": "activity", 202 + "tableTo": "task", 203 + "columnsFrom": ["task_id"], 204 + "columnsTo": ["id"], 205 + "onDelete": "cascade", 206 + "onUpdate": "cascade" 207 + }, 208 + "activity_user_id_user_id_fk": { 209 + "name": "activity_user_id_user_id_fk", 210 + "tableFrom": "activity", 211 + "tableTo": "user", 212 + "columnsFrom": ["user_id"], 213 + "columnsTo": ["id"], 214 + "onDelete": "cascade", 215 + "onUpdate": "cascade" 216 + } 217 + }, 218 + "compositePrimaryKeys": {}, 219 + "uniqueConstraints": {}, 220 + "policies": {}, 221 + "checkConstraints": {}, 222 + "isRLSEnabled": false 223 + }, 224 + "public.apikey": { 225 + "name": "apikey", 226 + "schema": "", 227 + "columns": { 228 + "id": { 229 + "name": "id", 230 + "type": "text", 231 + "primaryKey": true, 232 + "notNull": true 233 + }, 234 + "config_id": { 235 + "name": "config_id", 236 + "type": "text", 237 + "primaryKey": false, 238 + "notNull": true, 239 + "default": "'default'" 240 + }, 241 + "name": { 242 + "name": "name", 243 + "type": "text", 244 + "primaryKey": false, 245 + "notNull": false 246 + }, 247 + "start": { 248 + "name": "start", 249 + "type": "text", 250 + "primaryKey": false, 251 + "notNull": false 252 + }, 253 + "reference_id": { 254 + "name": "reference_id", 255 + "type": "text", 256 + "primaryKey": false, 257 + "notNull": true 258 + }, 259 + "prefix": { 260 + "name": "prefix", 261 + "type": "text", 262 + "primaryKey": false, 263 + "notNull": false 264 + }, 265 + "key": { 266 + "name": "key", 267 + "type": "text", 268 + "primaryKey": false, 269 + "notNull": true 270 + }, 271 + "user_id": { 272 + "name": "user_id", 273 + "type": "text", 274 + "primaryKey": false, 275 + "notNull": false 276 + }, 277 + "refill_interval": { 278 + "name": "refill_interval", 279 + "type": "integer", 280 + "primaryKey": false, 281 + "notNull": false 282 + }, 283 + "refill_amount": { 284 + "name": "refill_amount", 285 + "type": "integer", 286 + "primaryKey": false, 287 + "notNull": false 288 + }, 289 + "last_refill_at": { 290 + "name": "last_refill_at", 291 + "type": "timestamp", 292 + "primaryKey": false, 293 + "notNull": false 294 + }, 295 + "enabled": { 296 + "name": "enabled", 297 + "type": "boolean", 298 + "primaryKey": false, 299 + "notNull": false, 300 + "default": true 301 + }, 302 + "rate_limit_enabled": { 303 + "name": "rate_limit_enabled", 304 + "type": "boolean", 305 + "primaryKey": false, 306 + "notNull": false, 307 + "default": true 308 + }, 309 + "rate_limit_time_window": { 310 + "name": "rate_limit_time_window", 311 + "type": "integer", 312 + "primaryKey": false, 313 + "notNull": false, 314 + "default": 86400000 315 + }, 316 + "rate_limit_max": { 317 + "name": "rate_limit_max", 318 + "type": "integer", 319 + "primaryKey": false, 320 + "notNull": false, 321 + "default": 10 322 + }, 323 + "request_count": { 324 + "name": "request_count", 325 + "type": "integer", 326 + "primaryKey": false, 327 + "notNull": false, 328 + "default": 0 329 + }, 330 + "remaining": { 331 + "name": "remaining", 332 + "type": "integer", 333 + "primaryKey": false, 334 + "notNull": false 335 + }, 336 + "last_request": { 337 + "name": "last_request", 338 + "type": "timestamp", 339 + "primaryKey": false, 340 + "notNull": false 341 + }, 342 + "expires_at": { 343 + "name": "expires_at", 344 + "type": "timestamp", 345 + "primaryKey": false, 346 + "notNull": false 347 + }, 348 + "created_at": { 349 + "name": "created_at", 350 + "type": "timestamp", 351 + "primaryKey": false, 352 + "notNull": true 353 + }, 354 + "updated_at": { 355 + "name": "updated_at", 356 + "type": "timestamp", 357 + "primaryKey": false, 358 + "notNull": true 359 + }, 360 + "permissions": { 361 + "name": "permissions", 362 + "type": "text", 363 + "primaryKey": false, 364 + "notNull": false 365 + }, 366 + "metadata": { 367 + "name": "metadata", 368 + "type": "text", 369 + "primaryKey": false, 370 + "notNull": false 371 + } 372 + }, 373 + "indexes": { 374 + "apikey_configId_idx": { 375 + "name": "apikey_configId_idx", 376 + "columns": [ 377 + { 378 + "expression": "config_id", 379 + "isExpression": false, 380 + "asc": true, 381 + "nulls": "last" 382 + } 383 + ], 384 + "isUnique": false, 385 + "concurrently": false, 386 + "method": "btree", 387 + "with": {} 388 + }, 389 + "apikey_key_idx": { 390 + "name": "apikey_key_idx", 391 + "columns": [ 392 + { 393 + "expression": "key", 394 + "isExpression": false, 395 + "asc": true, 396 + "nulls": "last" 397 + } 398 + ], 399 + "isUnique": false, 400 + "concurrently": false, 401 + "method": "btree", 402 + "with": {} 403 + }, 404 + "apikey_referenceId_idx": { 405 + "name": "apikey_referenceId_idx", 406 + "columns": [ 407 + { 408 + "expression": "reference_id", 409 + "isExpression": false, 410 + "asc": true, 411 + "nulls": "last" 412 + } 413 + ], 414 + "isUnique": false, 415 + "concurrently": false, 416 + "method": "btree", 417 + "with": {} 418 + }, 419 + "apikey_userId_idx": { 420 + "name": "apikey_userId_idx", 421 + "columns": [ 422 + { 423 + "expression": "user_id", 424 + "isExpression": false, 425 + "asc": true, 426 + "nulls": "last" 427 + } 428 + ], 429 + "isUnique": false, 430 + "concurrently": false, 431 + "method": "btree", 432 + "with": {} 433 + } 434 + }, 435 + "foreignKeys": { 436 + "apikey_reference_id_user_id_fk": { 437 + "name": "apikey_reference_id_user_id_fk", 438 + "tableFrom": "apikey", 439 + "tableTo": "user", 440 + "columnsFrom": ["reference_id"], 441 + "columnsTo": ["id"], 442 + "onDelete": "cascade", 443 + "onUpdate": "no action" 444 + }, 445 + "apikey_user_id_user_id_fk": { 446 + "name": "apikey_user_id_user_id_fk", 447 + "tableFrom": "apikey", 448 + "tableTo": "user", 449 + "columnsFrom": ["user_id"], 450 + "columnsTo": ["id"], 451 + "onDelete": "cascade", 452 + "onUpdate": "no action" 453 + } 454 + }, 455 + "compositePrimaryKeys": {}, 456 + "uniqueConstraints": {}, 457 + "policies": {}, 458 + "checkConstraints": {}, 459 + "isRLSEnabled": false 460 + }, 461 + "public.asset": { 462 + "name": "asset", 463 + "schema": "", 464 + "columns": { 465 + "id": { 466 + "name": "id", 467 + "type": "text", 468 + "primaryKey": true, 469 + "notNull": true 470 + }, 471 + "workspace_id": { 472 + "name": "workspace_id", 473 + "type": "text", 474 + "primaryKey": false, 475 + "notNull": true 476 + }, 477 + "project_id": { 478 + "name": "project_id", 479 + "type": "text", 480 + "primaryKey": false, 481 + "notNull": true 482 + }, 483 + "task_id": { 484 + "name": "task_id", 485 + "type": "text", 486 + "primaryKey": false, 487 + "notNull": false 488 + }, 489 + "activity_id": { 490 + "name": "activity_id", 491 + "type": "text", 492 + "primaryKey": false, 493 + "notNull": false 494 + }, 495 + "object_key": { 496 + "name": "object_key", 497 + "type": "text", 498 + "primaryKey": false, 499 + "notNull": true 500 + }, 501 + "filename": { 502 + "name": "filename", 503 + "type": "text", 504 + "primaryKey": false, 505 + "notNull": true 506 + }, 507 + "mime_type": { 508 + "name": "mime_type", 509 + "type": "text", 510 + "primaryKey": false, 511 + "notNull": true 512 + }, 513 + "size": { 514 + "name": "size", 515 + "type": "integer", 516 + "primaryKey": false, 517 + "notNull": true 518 + }, 519 + "kind": { 520 + "name": "kind", 521 + "type": "text", 522 + "primaryKey": false, 523 + "notNull": true, 524 + "default": "'image'" 525 + }, 526 + "surface": { 527 + "name": "surface", 528 + "type": "text", 529 + "primaryKey": false, 530 + "notNull": true, 531 + "default": "'description'" 532 + }, 533 + "created_by": { 534 + "name": "created_by", 535 + "type": "text", 536 + "primaryKey": false, 537 + "notNull": false 538 + }, 539 + "created_at": { 540 + "name": "created_at", 541 + "type": "timestamp", 542 + "primaryKey": false, 543 + "notNull": true, 544 + "default": "now()" 545 + } 546 + }, 547 + "indexes": { 548 + "asset_workspaceId_idx": { 549 + "name": "asset_workspaceId_idx", 550 + "columns": [ 551 + { 552 + "expression": "workspace_id", 553 + "isExpression": false, 554 + "asc": true, 555 + "nulls": "last" 556 + } 557 + ], 558 + "isUnique": false, 559 + "concurrently": false, 560 + "method": "btree", 561 + "with": {} 562 + }, 563 + "asset_projectId_idx": { 564 + "name": "asset_projectId_idx", 565 + "columns": [ 566 + { 567 + "expression": "project_id", 568 + "isExpression": false, 569 + "asc": true, 570 + "nulls": "last" 571 + } 572 + ], 573 + "isUnique": false, 574 + "concurrently": false, 575 + "method": "btree", 576 + "with": {} 577 + }, 578 + "asset_taskId_idx": { 579 + "name": "asset_taskId_idx", 580 + "columns": [ 581 + { 582 + "expression": "task_id", 583 + "isExpression": false, 584 + "asc": true, 585 + "nulls": "last" 586 + } 587 + ], 588 + "isUnique": false, 589 + "concurrently": false, 590 + "method": "btree", 591 + "with": {} 592 + }, 593 + "asset_activityId_idx": { 594 + "name": "asset_activityId_idx", 595 + "columns": [ 596 + { 597 + "expression": "activity_id", 598 + "isExpression": false, 599 + "asc": true, 600 + "nulls": "last" 601 + } 602 + ], 603 + "isUnique": false, 604 + "concurrently": false, 605 + "method": "btree", 606 + "with": {} 607 + } 608 + }, 609 + "foreignKeys": { 610 + "asset_workspace_id_workspace_id_fk": { 611 + "name": "asset_workspace_id_workspace_id_fk", 612 + "tableFrom": "asset", 613 + "tableTo": "workspace", 614 + "columnsFrom": ["workspace_id"], 615 + "columnsTo": ["id"], 616 + "onDelete": "cascade", 617 + "onUpdate": "cascade" 618 + }, 619 + "asset_project_id_project_id_fk": { 620 + "name": "asset_project_id_project_id_fk", 621 + "tableFrom": "asset", 622 + "tableTo": "project", 623 + "columnsFrom": ["project_id"], 624 + "columnsTo": ["id"], 625 + "onDelete": "cascade", 626 + "onUpdate": "cascade" 627 + }, 628 + "asset_task_id_task_id_fk": { 629 + "name": "asset_task_id_task_id_fk", 630 + "tableFrom": "asset", 631 + "tableTo": "task", 632 + "columnsFrom": ["task_id"], 633 + "columnsTo": ["id"], 634 + "onDelete": "cascade", 635 + "onUpdate": "cascade" 636 + }, 637 + "asset_activity_id_activity_id_fk": { 638 + "name": "asset_activity_id_activity_id_fk", 639 + "tableFrom": "asset", 640 + "tableTo": "activity", 641 + "columnsFrom": ["activity_id"], 642 + "columnsTo": ["id"], 643 + "onDelete": "cascade", 644 + "onUpdate": "cascade" 645 + }, 646 + "asset_created_by_user_id_fk": { 647 + "name": "asset_created_by_user_id_fk", 648 + "tableFrom": "asset", 649 + "tableTo": "user", 650 + "columnsFrom": ["created_by"], 651 + "columnsTo": ["id"], 652 + "onDelete": "set null", 653 + "onUpdate": "cascade" 654 + } 655 + }, 656 + "compositePrimaryKeys": {}, 657 + "uniqueConstraints": { 658 + "asset_object_key_unique": { 659 + "name": "asset_object_key_unique", 660 + "nullsNotDistinct": false, 661 + "columns": ["object_key"] 662 + } 663 + }, 664 + "policies": {}, 665 + "checkConstraints": {}, 666 + "isRLSEnabled": false 667 + }, 668 + "public.column": { 669 + "name": "column", 670 + "schema": "", 671 + "columns": { 672 + "id": { 673 + "name": "id", 674 + "type": "text", 675 + "primaryKey": true, 676 + "notNull": true 677 + }, 678 + "project_id": { 679 + "name": "project_id", 680 + "type": "text", 681 + "primaryKey": false, 682 + "notNull": true 683 + }, 684 + "name": { 685 + "name": "name", 686 + "type": "text", 687 + "primaryKey": false, 688 + "notNull": true 689 + }, 690 + "slug": { 691 + "name": "slug", 692 + "type": "text", 693 + "primaryKey": false, 694 + "notNull": true 695 + }, 696 + "position": { 697 + "name": "position", 698 + "type": "integer", 699 + "primaryKey": false, 700 + "notNull": true, 701 + "default": 0 702 + }, 703 + "icon": { 704 + "name": "icon", 705 + "type": "text", 706 + "primaryKey": false, 707 + "notNull": false 708 + }, 709 + "color": { 710 + "name": "color", 711 + "type": "text", 712 + "primaryKey": false, 713 + "notNull": false 714 + }, 715 + "is_final": { 716 + "name": "is_final", 717 + "type": "boolean", 718 + "primaryKey": false, 719 + "notNull": true, 720 + "default": false 721 + }, 722 + "created_at": { 723 + "name": "created_at", 724 + "type": "timestamp", 725 + "primaryKey": false, 726 + "notNull": true, 727 + "default": "now()" 728 + }, 729 + "updated_at": { 730 + "name": "updated_at", 731 + "type": "timestamp", 732 + "primaryKey": false, 733 + "notNull": true, 734 + "default": "now()" 735 + } 736 + }, 737 + "indexes": { 738 + "column_projectId_idx": { 739 + "name": "column_projectId_idx", 740 + "columns": [ 741 + { 742 + "expression": "project_id", 743 + "isExpression": false, 744 + "asc": true, 745 + "nulls": "last" 746 + } 747 + ], 748 + "isUnique": false, 749 + "concurrently": false, 750 + "method": "btree", 751 + "with": {} 752 + } 753 + }, 754 + "foreignKeys": { 755 + "column_project_id_project_id_fk": { 756 + "name": "column_project_id_project_id_fk", 757 + "tableFrom": "column", 758 + "tableTo": "project", 759 + "columnsFrom": ["project_id"], 760 + "columnsTo": ["id"], 761 + "onDelete": "cascade", 762 + "onUpdate": "cascade" 763 + } 764 + }, 765 + "compositePrimaryKeys": {}, 766 + "uniqueConstraints": {}, 767 + "policies": {}, 768 + "checkConstraints": {}, 769 + "isRLSEnabled": false 770 + }, 771 + "public.comment": { 772 + "name": "comment", 773 + "schema": "", 774 + "columns": { 775 + "id": { 776 + "name": "id", 777 + "type": "text", 778 + "primaryKey": true, 779 + "notNull": true 780 + }, 781 + "task_id": { 782 + "name": "task_id", 783 + "type": "text", 784 + "primaryKey": false, 785 + "notNull": true 786 + }, 787 + "user_id": { 788 + "name": "user_id", 789 + "type": "text", 790 + "primaryKey": false, 791 + "notNull": true 792 + }, 793 + "content": { 794 + "name": "content", 795 + "type": "text", 796 + "primaryKey": false, 797 + "notNull": true 798 + }, 799 + "created_at": { 800 + "name": "created_at", 801 + "type": "timestamp", 802 + "primaryKey": false, 803 + "notNull": true, 804 + "default": "now()" 805 + }, 806 + "updated_at": { 807 + "name": "updated_at", 808 + "type": "timestamp", 809 + "primaryKey": false, 810 + "notNull": true, 811 + "default": "now()" 812 + } 813 + }, 814 + "indexes": { 815 + "comment_task_idx": { 816 + "name": "comment_task_idx", 817 + "columns": [ 818 + { 819 + "expression": "task_id", 820 + "isExpression": false, 821 + "asc": true, 822 + "nulls": "last" 823 + } 824 + ], 825 + "isUnique": false, 826 + "concurrently": false, 827 + "method": "btree", 828 + "with": {} 829 + }, 830 + "comment_user_idx": { 831 + "name": "comment_user_idx", 832 + "columns": [ 833 + { 834 + "expression": "user_id", 835 + "isExpression": false, 836 + "asc": true, 837 + "nulls": "last" 838 + } 839 + ], 840 + "isUnique": false, 841 + "concurrently": false, 842 + "method": "btree", 843 + "with": {} 844 + } 845 + }, 846 + "foreignKeys": { 847 + "comment_task_id_task_id_fk": { 848 + "name": "comment_task_id_task_id_fk", 849 + "tableFrom": "comment", 850 + "tableTo": "task", 851 + "columnsFrom": ["task_id"], 852 + "columnsTo": ["id"], 853 + "onDelete": "cascade", 854 + "onUpdate": "cascade" 855 + }, 856 + "comment_user_id_user_id_fk": { 857 + "name": "comment_user_id_user_id_fk", 858 + "tableFrom": "comment", 859 + "tableTo": "user", 860 + "columnsFrom": ["user_id"], 861 + "columnsTo": ["id"], 862 + "onDelete": "cascade", 863 + "onUpdate": "cascade" 864 + } 865 + }, 866 + "compositePrimaryKeys": {}, 867 + "uniqueConstraints": {}, 868 + "policies": {}, 869 + "checkConstraints": {}, 870 + "isRLSEnabled": false 871 + }, 872 + "public.external_link": { 873 + "name": "external_link", 874 + "schema": "", 875 + "columns": { 876 + "id": { 877 + "name": "id", 878 + "type": "text", 879 + "primaryKey": true, 880 + "notNull": true 881 + }, 882 + "task_id": { 883 + "name": "task_id", 884 + "type": "text", 885 + "primaryKey": false, 886 + "notNull": true 887 + }, 888 + "integration_id": { 889 + "name": "integration_id", 890 + "type": "text", 891 + "primaryKey": false, 892 + "notNull": true 893 + }, 894 + "resource_type": { 895 + "name": "resource_type", 896 + "type": "text", 897 + "primaryKey": false, 898 + "notNull": true 899 + }, 900 + "external_id": { 901 + "name": "external_id", 902 + "type": "text", 903 + "primaryKey": false, 904 + "notNull": true 905 + }, 906 + "url": { 907 + "name": "url", 908 + "type": "text", 909 + "primaryKey": false, 910 + "notNull": true 911 + }, 912 + "title": { 913 + "name": "title", 914 + "type": "text", 915 + "primaryKey": false, 916 + "notNull": false 917 + }, 918 + "metadata": { 919 + "name": "metadata", 920 + "type": "text", 921 + "primaryKey": false, 922 + "notNull": false 923 + }, 924 + "created_at": { 925 + "name": "created_at", 926 + "type": "timestamp", 927 + "primaryKey": false, 928 + "notNull": true, 929 + "default": "now()" 930 + }, 931 + "updated_at": { 932 + "name": "updated_at", 933 + "type": "timestamp", 934 + "primaryKey": false, 935 + "notNull": true, 936 + "default": "now()" 937 + } 938 + }, 939 + "indexes": { 940 + "external_link_taskId_idx": { 941 + "name": "external_link_taskId_idx", 942 + "columns": [ 943 + { 944 + "expression": "task_id", 945 + "isExpression": false, 946 + "asc": true, 947 + "nulls": "last" 948 + } 949 + ], 950 + "isUnique": false, 951 + "concurrently": false, 952 + "method": "btree", 953 + "with": {} 954 + }, 955 + "external_link_integrationId_idx": { 956 + "name": "external_link_integrationId_idx", 957 + "columns": [ 958 + { 959 + "expression": "integration_id", 960 + "isExpression": false, 961 + "asc": true, 962 + "nulls": "last" 963 + } 964 + ], 965 + "isUnique": false, 966 + "concurrently": false, 967 + "method": "btree", 968 + "with": {} 969 + }, 970 + "external_link_externalId_idx": { 971 + "name": "external_link_externalId_idx", 972 + "columns": [ 973 + { 974 + "expression": "external_id", 975 + "isExpression": false, 976 + "asc": true, 977 + "nulls": "last" 978 + } 979 + ], 980 + "isUnique": false, 981 + "concurrently": false, 982 + "method": "btree", 983 + "with": {} 984 + }, 985 + "external_link_resourceType_idx": { 986 + "name": "external_link_resourceType_idx", 987 + "columns": [ 988 + { 989 + "expression": "resource_type", 990 + "isExpression": false, 991 + "asc": true, 992 + "nulls": "last" 993 + } 994 + ], 995 + "isUnique": false, 996 + "concurrently": false, 997 + "method": "btree", 998 + "with": {} 999 + } 1000 + }, 1001 + "foreignKeys": { 1002 + "external_link_task_id_task_id_fk": { 1003 + "name": "external_link_task_id_task_id_fk", 1004 + "tableFrom": "external_link", 1005 + "tableTo": "task", 1006 + "columnsFrom": ["task_id"], 1007 + "columnsTo": ["id"], 1008 + "onDelete": "cascade", 1009 + "onUpdate": "cascade" 1010 + }, 1011 + "external_link_integration_id_integration_id_fk": { 1012 + "name": "external_link_integration_id_integration_id_fk", 1013 + "tableFrom": "external_link", 1014 + "tableTo": "integration", 1015 + "columnsFrom": ["integration_id"], 1016 + "columnsTo": ["id"], 1017 + "onDelete": "cascade", 1018 + "onUpdate": "cascade" 1019 + } 1020 + }, 1021 + "compositePrimaryKeys": {}, 1022 + "uniqueConstraints": {}, 1023 + "policies": {}, 1024 + "checkConstraints": {}, 1025 + "isRLSEnabled": false 1026 + }, 1027 + "public.github_integration": { 1028 + "name": "github_integration", 1029 + "schema": "", 1030 + "columns": { 1031 + "id": { 1032 + "name": "id", 1033 + "type": "text", 1034 + "primaryKey": true, 1035 + "notNull": true 1036 + }, 1037 + "project_id": { 1038 + "name": "project_id", 1039 + "type": "text", 1040 + "primaryKey": false, 1041 + "notNull": true 1042 + }, 1043 + "repository_owner": { 1044 + "name": "repository_owner", 1045 + "type": "text", 1046 + "primaryKey": false, 1047 + "notNull": true 1048 + }, 1049 + "repository_name": { 1050 + "name": "repository_name", 1051 + "type": "text", 1052 + "primaryKey": false, 1053 + "notNull": true 1054 + }, 1055 + "installation_id": { 1056 + "name": "installation_id", 1057 + "type": "integer", 1058 + "primaryKey": false, 1059 + "notNull": false 1060 + }, 1061 + "is_active": { 1062 + "name": "is_active", 1063 + "type": "boolean", 1064 + "primaryKey": false, 1065 + "notNull": false, 1066 + "default": true 1067 + }, 1068 + "created_at": { 1069 + "name": "created_at", 1070 + "type": "timestamp", 1071 + "primaryKey": false, 1072 + "notNull": true, 1073 + "default": "now()" 1074 + }, 1075 + "updated_at": { 1076 + "name": "updated_at", 1077 + "type": "timestamp", 1078 + "primaryKey": false, 1079 + "notNull": true, 1080 + "default": "now()" 1081 + } 1082 + }, 1083 + "indexes": {}, 1084 + "foreignKeys": { 1085 + "github_integration_project_id_project_id_fk": { 1086 + "name": "github_integration_project_id_project_id_fk", 1087 + "tableFrom": "github_integration", 1088 + "tableTo": "project", 1089 + "columnsFrom": ["project_id"], 1090 + "columnsTo": ["id"], 1091 + "onDelete": "cascade", 1092 + "onUpdate": "cascade" 1093 + } 1094 + }, 1095 + "compositePrimaryKeys": {}, 1096 + "uniqueConstraints": { 1097 + "github_integration_project_id_unique": { 1098 + "name": "github_integration_project_id_unique", 1099 + "nullsNotDistinct": false, 1100 + "columns": ["project_id"] 1101 + } 1102 + }, 1103 + "policies": {}, 1104 + "checkConstraints": {}, 1105 + "isRLSEnabled": false 1106 + }, 1107 + "public.integration": { 1108 + "name": "integration", 1109 + "schema": "", 1110 + "columns": { 1111 + "id": { 1112 + "name": "id", 1113 + "type": "text", 1114 + "primaryKey": true, 1115 + "notNull": true 1116 + }, 1117 + "project_id": { 1118 + "name": "project_id", 1119 + "type": "text", 1120 + "primaryKey": false, 1121 + "notNull": true 1122 + }, 1123 + "type": { 1124 + "name": "type", 1125 + "type": "text", 1126 + "primaryKey": false, 1127 + "notNull": true 1128 + }, 1129 + "config": { 1130 + "name": "config", 1131 + "type": "text", 1132 + "primaryKey": false, 1133 + "notNull": true 1134 + }, 1135 + "is_active": { 1136 + "name": "is_active", 1137 + "type": "boolean", 1138 + "primaryKey": false, 1139 + "notNull": false, 1140 + "default": true 1141 + }, 1142 + "created_at": { 1143 + "name": "created_at", 1144 + "type": "timestamp", 1145 + "primaryKey": false, 1146 + "notNull": true, 1147 + "default": "now()" 1148 + }, 1149 + "updated_at": { 1150 + "name": "updated_at", 1151 + "type": "timestamp", 1152 + "primaryKey": false, 1153 + "notNull": true, 1154 + "default": "now()" 1155 + } 1156 + }, 1157 + "indexes": { 1158 + "integration_projectId_idx": { 1159 + "name": "integration_projectId_idx", 1160 + "columns": [ 1161 + { 1162 + "expression": "project_id", 1163 + "isExpression": false, 1164 + "asc": true, 1165 + "nulls": "last" 1166 + } 1167 + ], 1168 + "isUnique": false, 1169 + "concurrently": false, 1170 + "method": "btree", 1171 + "with": {} 1172 + }, 1173 + "integration_type_idx": { 1174 + "name": "integration_type_idx", 1175 + "columns": [ 1176 + { 1177 + "expression": "type", 1178 + "isExpression": false, 1179 + "asc": true, 1180 + "nulls": "last" 1181 + } 1182 + ], 1183 + "isUnique": false, 1184 + "concurrently": false, 1185 + "method": "btree", 1186 + "with": {} 1187 + } 1188 + }, 1189 + "foreignKeys": { 1190 + "integration_project_id_project_id_fk": { 1191 + "name": "integration_project_id_project_id_fk", 1192 + "tableFrom": "integration", 1193 + "tableTo": "project", 1194 + "columnsFrom": ["project_id"], 1195 + "columnsTo": ["id"], 1196 + "onDelete": "cascade", 1197 + "onUpdate": "cascade" 1198 + } 1199 + }, 1200 + "compositePrimaryKeys": {}, 1201 + "uniqueConstraints": { 1202 + "integration_project_type_unique": { 1203 + "name": "integration_project_type_unique", 1204 + "nullsNotDistinct": false, 1205 + "columns": ["project_id", "type"] 1206 + } 1207 + }, 1208 + "policies": {}, 1209 + "checkConstraints": {}, 1210 + "isRLSEnabled": false 1211 + }, 1212 + "public.invitation": { 1213 + "name": "invitation", 1214 + "schema": "", 1215 + "columns": { 1216 + "id": { 1217 + "name": "id", 1218 + "type": "text", 1219 + "primaryKey": true, 1220 + "notNull": true 1221 + }, 1222 + "workspace_id": { 1223 + "name": "workspace_id", 1224 + "type": "text", 1225 + "primaryKey": false, 1226 + "notNull": true 1227 + }, 1228 + "email": { 1229 + "name": "email", 1230 + "type": "text", 1231 + "primaryKey": false, 1232 + "notNull": true 1233 + }, 1234 + "role": { 1235 + "name": "role", 1236 + "type": "text", 1237 + "primaryKey": false, 1238 + "notNull": false 1239 + }, 1240 + "team_id": { 1241 + "name": "team_id", 1242 + "type": "text", 1243 + "primaryKey": false, 1244 + "notNull": false 1245 + }, 1246 + "status": { 1247 + "name": "status", 1248 + "type": "text", 1249 + "primaryKey": false, 1250 + "notNull": true, 1251 + "default": "'pending'" 1252 + }, 1253 + "expires_at": { 1254 + "name": "expires_at", 1255 + "type": "timestamp", 1256 + "primaryKey": false, 1257 + "notNull": true 1258 + }, 1259 + "created_at": { 1260 + "name": "created_at", 1261 + "type": "timestamp", 1262 + "primaryKey": false, 1263 + "notNull": true, 1264 + "default": "now()" 1265 + }, 1266 + "inviter_id": { 1267 + "name": "inviter_id", 1268 + "type": "text", 1269 + "primaryKey": false, 1270 + "notNull": true 1271 + } 1272 + }, 1273 + "indexes": { 1274 + "invitation_workspaceId_idx": { 1275 + "name": "invitation_workspaceId_idx", 1276 + "columns": [ 1277 + { 1278 + "expression": "workspace_id", 1279 + "isExpression": false, 1280 + "asc": true, 1281 + "nulls": "last" 1282 + } 1283 + ], 1284 + "isUnique": false, 1285 + "concurrently": false, 1286 + "method": "btree", 1287 + "with": {} 1288 + }, 1289 + "invitation_email_idx": { 1290 + "name": "invitation_email_idx", 1291 + "columns": [ 1292 + { 1293 + "expression": "email", 1294 + "isExpression": false, 1295 + "asc": true, 1296 + "nulls": "last" 1297 + } 1298 + ], 1299 + "isUnique": false, 1300 + "concurrently": false, 1301 + "method": "btree", 1302 + "with": {} 1303 + } 1304 + }, 1305 + "foreignKeys": { 1306 + "invitation_workspace_id_workspace_id_fk": { 1307 + "name": "invitation_workspace_id_workspace_id_fk", 1308 + "tableFrom": "invitation", 1309 + "tableTo": "workspace", 1310 + "columnsFrom": ["workspace_id"], 1311 + "columnsTo": ["id"], 1312 + "onDelete": "cascade", 1313 + "onUpdate": "no action" 1314 + }, 1315 + "invitation_inviter_id_user_id_fk": { 1316 + "name": "invitation_inviter_id_user_id_fk", 1317 + "tableFrom": "invitation", 1318 + "tableTo": "user", 1319 + "columnsFrom": ["inviter_id"], 1320 + "columnsTo": ["id"], 1321 + "onDelete": "cascade", 1322 + "onUpdate": "no action" 1323 + } 1324 + }, 1325 + "compositePrimaryKeys": {}, 1326 + "uniqueConstraints": {}, 1327 + "policies": {}, 1328 + "checkConstraints": {}, 1329 + "isRLSEnabled": false 1330 + }, 1331 + "public.label": { 1332 + "name": "label", 1333 + "schema": "", 1334 + "columns": { 1335 + "id": { 1336 + "name": "id", 1337 + "type": "text", 1338 + "primaryKey": true, 1339 + "notNull": true 1340 + }, 1341 + "name": { 1342 + "name": "name", 1343 + "type": "text", 1344 + "primaryKey": false, 1345 + "notNull": true 1346 + }, 1347 + "color": { 1348 + "name": "color", 1349 + "type": "text", 1350 + "primaryKey": false, 1351 + "notNull": true 1352 + }, 1353 + "created_at": { 1354 + "name": "created_at", 1355 + "type": "timestamp", 1356 + "primaryKey": false, 1357 + "notNull": true, 1358 + "default": "now()" 1359 + }, 1360 + "task_id": { 1361 + "name": "task_id", 1362 + "type": "text", 1363 + "primaryKey": false, 1364 + "notNull": false 1365 + }, 1366 + "workspace_id": { 1367 + "name": "workspace_id", 1368 + "type": "text", 1369 + "primaryKey": false, 1370 + "notNull": false 1371 + } 1372 + }, 1373 + "indexes": {}, 1374 + "foreignKeys": { 1375 + "label_task_id_task_id_fk": { 1376 + "name": "label_task_id_task_id_fk", 1377 + "tableFrom": "label", 1378 + "tableTo": "task", 1379 + "columnsFrom": ["task_id"], 1380 + "columnsTo": ["id"], 1381 + "onDelete": "cascade", 1382 + "onUpdate": "cascade" 1383 + }, 1384 + "label_workspace_id_workspace_id_fk": { 1385 + "name": "label_workspace_id_workspace_id_fk", 1386 + "tableFrom": "label", 1387 + "tableTo": "workspace", 1388 + "columnsFrom": ["workspace_id"], 1389 + "columnsTo": ["id"], 1390 + "onDelete": "cascade", 1391 + "onUpdate": "cascade" 1392 + } 1393 + }, 1394 + "compositePrimaryKeys": {}, 1395 + "uniqueConstraints": {}, 1396 + "policies": {}, 1397 + "checkConstraints": {}, 1398 + "isRLSEnabled": false 1399 + }, 1400 + "public.notification": { 1401 + "name": "notification", 1402 + "schema": "", 1403 + "columns": { 1404 + "id": { 1405 + "name": "id", 1406 + "type": "text", 1407 + "primaryKey": true, 1408 + "notNull": true 1409 + }, 1410 + "user_id": { 1411 + "name": "user_id", 1412 + "type": "text", 1413 + "primaryKey": false, 1414 + "notNull": true 1415 + }, 1416 + "title": { 1417 + "name": "title", 1418 + "type": "text", 1419 + "primaryKey": false, 1420 + "notNull": false 1421 + }, 1422 + "content": { 1423 + "name": "content", 1424 + "type": "text", 1425 + "primaryKey": false, 1426 + "notNull": false 1427 + }, 1428 + "type": { 1429 + "name": "type", 1430 + "type": "text", 1431 + "primaryKey": false, 1432 + "notNull": true, 1433 + "default": "'info'" 1434 + }, 1435 + "event_data": { 1436 + "name": "event_data", 1437 + "type": "jsonb", 1438 + "primaryKey": false, 1439 + "notNull": false 1440 + }, 1441 + "is_read": { 1442 + "name": "is_read", 1443 + "type": "boolean", 1444 + "primaryKey": false, 1445 + "notNull": false, 1446 + "default": false 1447 + }, 1448 + "resource_id": { 1449 + "name": "resource_id", 1450 + "type": "text", 1451 + "primaryKey": false, 1452 + "notNull": false 1453 + }, 1454 + "resource_type": { 1455 + "name": "resource_type", 1456 + "type": "text", 1457 + "primaryKey": false, 1458 + "notNull": false 1459 + }, 1460 + "created_at": { 1461 + "name": "created_at", 1462 + "type": "timestamp with time zone", 1463 + "primaryKey": false, 1464 + "notNull": true, 1465 + "default": "now()" 1466 + } 1467 + }, 1468 + "indexes": {}, 1469 + "foreignKeys": { 1470 + "notification_user_id_user_id_fk": { 1471 + "name": "notification_user_id_user_id_fk", 1472 + "tableFrom": "notification", 1473 + "tableTo": "user", 1474 + "columnsFrom": ["user_id"], 1475 + "columnsTo": ["id"], 1476 + "onDelete": "cascade", 1477 + "onUpdate": "cascade" 1478 + } 1479 + }, 1480 + "compositePrimaryKeys": {}, 1481 + "uniqueConstraints": {}, 1482 + "policies": {}, 1483 + "checkConstraints": {}, 1484 + "isRLSEnabled": false 1485 + }, 1486 + "public.project": { 1487 + "name": "project", 1488 + "schema": "", 1489 + "columns": { 1490 + "id": { 1491 + "name": "id", 1492 + "type": "text", 1493 + "primaryKey": true, 1494 + "notNull": true 1495 + }, 1496 + "workspace_id": { 1497 + "name": "workspace_id", 1498 + "type": "text", 1499 + "primaryKey": false, 1500 + "notNull": true 1501 + }, 1502 + "slug": { 1503 + "name": "slug", 1504 + "type": "text", 1505 + "primaryKey": false, 1506 + "notNull": true 1507 + }, 1508 + "icon": { 1509 + "name": "icon", 1510 + "type": "text", 1511 + "primaryKey": false, 1512 + "notNull": false, 1513 + "default": "'Layout'" 1514 + }, 1515 + "name": { 1516 + "name": "name", 1517 + "type": "text", 1518 + "primaryKey": false, 1519 + "notNull": true 1520 + }, 1521 + "description": { 1522 + "name": "description", 1523 + "type": "text", 1524 + "primaryKey": false, 1525 + "notNull": false 1526 + }, 1527 + "created_at": { 1528 + "name": "created_at", 1529 + "type": "timestamp", 1530 + "primaryKey": false, 1531 + "notNull": true, 1532 + "default": "now()" 1533 + }, 1534 + "is_public": { 1535 + "name": "is_public", 1536 + "type": "boolean", 1537 + "primaryKey": false, 1538 + "notNull": false, 1539 + "default": false 1540 + }, 1541 + "archived_at": { 1542 + "name": "archived_at", 1543 + "type": "timestamp", 1544 + "primaryKey": false, 1545 + "notNull": false 1546 + } 1547 + }, 1548 + "indexes": {}, 1549 + "foreignKeys": { 1550 + "project_workspace_id_workspace_id_fk": { 1551 + "name": "project_workspace_id_workspace_id_fk", 1552 + "tableFrom": "project", 1553 + "tableTo": "workspace", 1554 + "columnsFrom": ["workspace_id"], 1555 + "columnsTo": ["id"], 1556 + "onDelete": "cascade", 1557 + "onUpdate": "cascade" 1558 + } 1559 + }, 1560 + "compositePrimaryKeys": {}, 1561 + "uniqueConstraints": {}, 1562 + "policies": {}, 1563 + "checkConstraints": {}, 1564 + "isRLSEnabled": false 1565 + }, 1566 + "public.session": { 1567 + "name": "session", 1568 + "schema": "", 1569 + "columns": { 1570 + "id": { 1571 + "name": "id", 1572 + "type": "text", 1573 + "primaryKey": true, 1574 + "notNull": true 1575 + }, 1576 + "expires_at": { 1577 + "name": "expires_at", 1578 + "type": "timestamp", 1579 + "primaryKey": false, 1580 + "notNull": true 1581 + }, 1582 + "token": { 1583 + "name": "token", 1584 + "type": "text", 1585 + "primaryKey": false, 1586 + "notNull": true 1587 + }, 1588 + "created_at": { 1589 + "name": "created_at", 1590 + "type": "timestamp", 1591 + "primaryKey": false, 1592 + "notNull": true, 1593 + "default": "now()" 1594 + }, 1595 + "updated_at": { 1596 + "name": "updated_at", 1597 + "type": "timestamp", 1598 + "primaryKey": false, 1599 + "notNull": true 1600 + }, 1601 + "ip_address": { 1602 + "name": "ip_address", 1603 + "type": "text", 1604 + "primaryKey": false, 1605 + "notNull": false 1606 + }, 1607 + "user_agent": { 1608 + "name": "user_agent", 1609 + "type": "text", 1610 + "primaryKey": false, 1611 + "notNull": false 1612 + }, 1613 + "user_id": { 1614 + "name": "user_id", 1615 + "type": "text", 1616 + "primaryKey": false, 1617 + "notNull": true 1618 + }, 1619 + "active_organization_id": { 1620 + "name": "active_organization_id", 1621 + "type": "text", 1622 + "primaryKey": false, 1623 + "notNull": false 1624 + }, 1625 + "active_team_id": { 1626 + "name": "active_team_id", 1627 + "type": "text", 1628 + "primaryKey": false, 1629 + "notNull": false 1630 + } 1631 + }, 1632 + "indexes": { 1633 + "session_userId_idx": { 1634 + "name": "session_userId_idx", 1635 + "columns": [ 1636 + { 1637 + "expression": "user_id", 1638 + "isExpression": false, 1639 + "asc": true, 1640 + "nulls": "last" 1641 + } 1642 + ], 1643 + "isUnique": false, 1644 + "concurrently": false, 1645 + "method": "btree", 1646 + "with": {} 1647 + } 1648 + }, 1649 + "foreignKeys": { 1650 + "session_user_id_user_id_fk": { 1651 + "name": "session_user_id_user_id_fk", 1652 + "tableFrom": "session", 1653 + "tableTo": "user", 1654 + "columnsFrom": ["user_id"], 1655 + "columnsTo": ["id"], 1656 + "onDelete": "cascade", 1657 + "onUpdate": "no action" 1658 + } 1659 + }, 1660 + "compositePrimaryKeys": {}, 1661 + "uniqueConstraints": { 1662 + "session_token_unique": { 1663 + "name": "session_token_unique", 1664 + "nullsNotDistinct": false, 1665 + "columns": ["token"] 1666 + } 1667 + }, 1668 + "policies": {}, 1669 + "checkConstraints": {}, 1670 + "isRLSEnabled": false 1671 + }, 1672 + "public.task_relation": { 1673 + "name": "task_relation", 1674 + "schema": "", 1675 + "columns": { 1676 + "id": { 1677 + "name": "id", 1678 + "type": "text", 1679 + "primaryKey": true, 1680 + "notNull": true 1681 + }, 1682 + "source_task_id": { 1683 + "name": "source_task_id", 1684 + "type": "text", 1685 + "primaryKey": false, 1686 + "notNull": true 1687 + }, 1688 + "target_task_id": { 1689 + "name": "target_task_id", 1690 + "type": "text", 1691 + "primaryKey": false, 1692 + "notNull": true 1693 + }, 1694 + "relation_type": { 1695 + "name": "relation_type", 1696 + "type": "text", 1697 + "primaryKey": false, 1698 + "notNull": true 1699 + }, 1700 + "created_at": { 1701 + "name": "created_at", 1702 + "type": "timestamp", 1703 + "primaryKey": false, 1704 + "notNull": true, 1705 + "default": "now()" 1706 + } 1707 + }, 1708 + "indexes": { 1709 + "task_relation_source_idx": { 1710 + "name": "task_relation_source_idx", 1711 + "columns": [ 1712 + { 1713 + "expression": "source_task_id", 1714 + "isExpression": false, 1715 + "asc": true, 1716 + "nulls": "last" 1717 + } 1718 + ], 1719 + "isUnique": false, 1720 + "concurrently": false, 1721 + "method": "btree", 1722 + "with": {} 1723 + }, 1724 + "task_relation_target_idx": { 1725 + "name": "task_relation_target_idx", 1726 + "columns": [ 1727 + { 1728 + "expression": "target_task_id", 1729 + "isExpression": false, 1730 + "asc": true, 1731 + "nulls": "last" 1732 + } 1733 + ], 1734 + "isUnique": false, 1735 + "concurrently": false, 1736 + "method": "btree", 1737 + "with": {} 1738 + } 1739 + }, 1740 + "foreignKeys": { 1741 + "task_relation_source_task_id_task_id_fk": { 1742 + "name": "task_relation_source_task_id_task_id_fk", 1743 + "tableFrom": "task_relation", 1744 + "tableTo": "task", 1745 + "columnsFrom": ["source_task_id"], 1746 + "columnsTo": ["id"], 1747 + "onDelete": "cascade", 1748 + "onUpdate": "cascade" 1749 + }, 1750 + "task_relation_target_task_id_task_id_fk": { 1751 + "name": "task_relation_target_task_id_task_id_fk", 1752 + "tableFrom": "task_relation", 1753 + "tableTo": "task", 1754 + "columnsFrom": ["target_task_id"], 1755 + "columnsTo": ["id"], 1756 + "onDelete": "cascade", 1757 + "onUpdate": "cascade" 1758 + } 1759 + }, 1760 + "compositePrimaryKeys": {}, 1761 + "uniqueConstraints": {}, 1762 + "policies": {}, 1763 + "checkConstraints": {}, 1764 + "isRLSEnabled": false 1765 + }, 1766 + "public.task": { 1767 + "name": "task", 1768 + "schema": "", 1769 + "columns": { 1770 + "id": { 1771 + "name": "id", 1772 + "type": "text", 1773 + "primaryKey": true, 1774 + "notNull": true 1775 + }, 1776 + "project_id": { 1777 + "name": "project_id", 1778 + "type": "text", 1779 + "primaryKey": false, 1780 + "notNull": true 1781 + }, 1782 + "position": { 1783 + "name": "position", 1784 + "type": "integer", 1785 + "primaryKey": false, 1786 + "notNull": false, 1787 + "default": 0 1788 + }, 1789 + "number": { 1790 + "name": "number", 1791 + "type": "integer", 1792 + "primaryKey": false, 1793 + "notNull": false, 1794 + "default": 1 1795 + }, 1796 + "assignee_id": { 1797 + "name": "assignee_id", 1798 + "type": "text", 1799 + "primaryKey": false, 1800 + "notNull": false 1801 + }, 1802 + "title": { 1803 + "name": "title", 1804 + "type": "text", 1805 + "primaryKey": false, 1806 + "notNull": true 1807 + }, 1808 + "description": { 1809 + "name": "description", 1810 + "type": "text", 1811 + "primaryKey": false, 1812 + "notNull": false 1813 + }, 1814 + "status": { 1815 + "name": "status", 1816 + "type": "text", 1817 + "primaryKey": false, 1818 + "notNull": true, 1819 + "default": "'to-do'" 1820 + }, 1821 + "column_id": { 1822 + "name": "column_id", 1823 + "type": "text", 1824 + "primaryKey": false, 1825 + "notNull": false 1826 + }, 1827 + "priority": { 1828 + "name": "priority", 1829 + "type": "text", 1830 + "primaryKey": false, 1831 + "notNull": false, 1832 + "default": "'low'" 1833 + }, 1834 + "start_date": { 1835 + "name": "start_date", 1836 + "type": "timestamp", 1837 + "primaryKey": false, 1838 + "notNull": false 1839 + }, 1840 + "due_date": { 1841 + "name": "due_date", 1842 + "type": "timestamp", 1843 + "primaryKey": false, 1844 + "notNull": false 1845 + }, 1846 + "created_at": { 1847 + "name": "created_at", 1848 + "type": "timestamp", 1849 + "primaryKey": false, 1850 + "notNull": true, 1851 + "default": "now()" 1852 + } 1853 + }, 1854 + "indexes": {}, 1855 + "foreignKeys": { 1856 + "task_project_id_project_id_fk": { 1857 + "name": "task_project_id_project_id_fk", 1858 + "tableFrom": "task", 1859 + "tableTo": "project", 1860 + "columnsFrom": ["project_id"], 1861 + "columnsTo": ["id"], 1862 + "onDelete": "cascade", 1863 + "onUpdate": "cascade" 1864 + }, 1865 + "task_assignee_id_user_id_fk": { 1866 + "name": "task_assignee_id_user_id_fk", 1867 + "tableFrom": "task", 1868 + "tableTo": "user", 1869 + "columnsFrom": ["assignee_id"], 1870 + "columnsTo": ["id"], 1871 + "onDelete": "cascade", 1872 + "onUpdate": "cascade" 1873 + }, 1874 + "task_column_id_column_id_fk": { 1875 + "name": "task_column_id_column_id_fk", 1876 + "tableFrom": "task", 1877 + "tableTo": "column", 1878 + "columnsFrom": ["column_id"], 1879 + "columnsTo": ["id"], 1880 + "onDelete": "set null", 1881 + "onUpdate": "cascade" 1882 + } 1883 + }, 1884 + "compositePrimaryKeys": {}, 1885 + "uniqueConstraints": {}, 1886 + "policies": {}, 1887 + "checkConstraints": {}, 1888 + "isRLSEnabled": false 1889 + }, 1890 + "public.team": { 1891 + "name": "team", 1892 + "schema": "", 1893 + "columns": { 1894 + "id": { 1895 + "name": "id", 1896 + "type": "text", 1897 + "primaryKey": true, 1898 + "notNull": true 1899 + }, 1900 + "name": { 1901 + "name": "name", 1902 + "type": "text", 1903 + "primaryKey": false, 1904 + "notNull": true 1905 + }, 1906 + "workspace_id": { 1907 + "name": "workspace_id", 1908 + "type": "text", 1909 + "primaryKey": false, 1910 + "notNull": true 1911 + }, 1912 + "created_at": { 1913 + "name": "created_at", 1914 + "type": "timestamp", 1915 + "primaryKey": false, 1916 + "notNull": true 1917 + }, 1918 + "updated_at": { 1919 + "name": "updated_at", 1920 + "type": "timestamp", 1921 + "primaryKey": false, 1922 + "notNull": false 1923 + } 1924 + }, 1925 + "indexes": { 1926 + "team_workspaceId_idx": { 1927 + "name": "team_workspaceId_idx", 1928 + "columns": [ 1929 + { 1930 + "expression": "workspace_id", 1931 + "isExpression": false, 1932 + "asc": true, 1933 + "nulls": "last" 1934 + } 1935 + ], 1936 + "isUnique": false, 1937 + "concurrently": false, 1938 + "method": "btree", 1939 + "with": {} 1940 + } 1941 + }, 1942 + "foreignKeys": { 1943 + "team_workspace_id_workspace_id_fk": { 1944 + "name": "team_workspace_id_workspace_id_fk", 1945 + "tableFrom": "team", 1946 + "tableTo": "workspace", 1947 + "columnsFrom": ["workspace_id"], 1948 + "columnsTo": ["id"], 1949 + "onDelete": "cascade", 1950 + "onUpdate": "no action" 1951 + } 1952 + }, 1953 + "compositePrimaryKeys": {}, 1954 + "uniqueConstraints": {}, 1955 + "policies": {}, 1956 + "checkConstraints": {}, 1957 + "isRLSEnabled": false 1958 + }, 1959 + "public.team_member": { 1960 + "name": "team_member", 1961 + "schema": "", 1962 + "columns": { 1963 + "id": { 1964 + "name": "id", 1965 + "type": "text", 1966 + "primaryKey": true, 1967 + "notNull": true 1968 + }, 1969 + "team_id": { 1970 + "name": "team_id", 1971 + "type": "text", 1972 + "primaryKey": false, 1973 + "notNull": true 1974 + }, 1975 + "user_id": { 1976 + "name": "user_id", 1977 + "type": "text", 1978 + "primaryKey": false, 1979 + "notNull": true 1980 + }, 1981 + "created_at": { 1982 + "name": "created_at", 1983 + "type": "timestamp", 1984 + "primaryKey": false, 1985 + "notNull": false 1986 + } 1987 + }, 1988 + "indexes": { 1989 + "teamMember_teamId_idx": { 1990 + "name": "teamMember_teamId_idx", 1991 + "columns": [ 1992 + { 1993 + "expression": "team_id", 1994 + "isExpression": false, 1995 + "asc": true, 1996 + "nulls": "last" 1997 + } 1998 + ], 1999 + "isUnique": false, 2000 + "concurrently": false, 2001 + "method": "btree", 2002 + "with": {} 2003 + }, 2004 + "teamMember_userId_idx": { 2005 + "name": "teamMember_userId_idx", 2006 + "columns": [ 2007 + { 2008 + "expression": "user_id", 2009 + "isExpression": false, 2010 + "asc": true, 2011 + "nulls": "last" 2012 + } 2013 + ], 2014 + "isUnique": false, 2015 + "concurrently": false, 2016 + "method": "btree", 2017 + "with": {} 2018 + } 2019 + }, 2020 + "foreignKeys": { 2021 + "team_member_team_id_team_id_fk": { 2022 + "name": "team_member_team_id_team_id_fk", 2023 + "tableFrom": "team_member", 2024 + "tableTo": "team", 2025 + "columnsFrom": ["team_id"], 2026 + "columnsTo": ["id"], 2027 + "onDelete": "cascade", 2028 + "onUpdate": "no action" 2029 + }, 2030 + "team_member_user_id_user_id_fk": { 2031 + "name": "team_member_user_id_user_id_fk", 2032 + "tableFrom": "team_member", 2033 + "tableTo": "user", 2034 + "columnsFrom": ["user_id"], 2035 + "columnsTo": ["id"], 2036 + "onDelete": "cascade", 2037 + "onUpdate": "no action" 2038 + } 2039 + }, 2040 + "compositePrimaryKeys": {}, 2041 + "uniqueConstraints": {}, 2042 + "policies": {}, 2043 + "checkConstraints": {}, 2044 + "isRLSEnabled": false 2045 + }, 2046 + "public.time_entry": { 2047 + "name": "time_entry", 2048 + "schema": "", 2049 + "columns": { 2050 + "id": { 2051 + "name": "id", 2052 + "type": "text", 2053 + "primaryKey": true, 2054 + "notNull": true 2055 + }, 2056 + "task_id": { 2057 + "name": "task_id", 2058 + "type": "text", 2059 + "primaryKey": false, 2060 + "notNull": true 2061 + }, 2062 + "user_id": { 2063 + "name": "user_id", 2064 + "type": "text", 2065 + "primaryKey": false, 2066 + "notNull": false 2067 + }, 2068 + "description": { 2069 + "name": "description", 2070 + "type": "text", 2071 + "primaryKey": false, 2072 + "notNull": false 2073 + }, 2074 + "start_time": { 2075 + "name": "start_time", 2076 + "type": "timestamp", 2077 + "primaryKey": false, 2078 + "notNull": true 2079 + }, 2080 + "end_time": { 2081 + "name": "end_time", 2082 + "type": "timestamp", 2083 + "primaryKey": false, 2084 + "notNull": false 2085 + }, 2086 + "duration": { 2087 + "name": "duration", 2088 + "type": "integer", 2089 + "primaryKey": false, 2090 + "notNull": false, 2091 + "default": 0 2092 + }, 2093 + "created_at": { 2094 + "name": "created_at", 2095 + "type": "timestamp", 2096 + "primaryKey": false, 2097 + "notNull": true, 2098 + "default": "now()" 2099 + } 2100 + }, 2101 + "indexes": {}, 2102 + "foreignKeys": { 2103 + "time_entry_task_id_task_id_fk": { 2104 + "name": "time_entry_task_id_task_id_fk", 2105 + "tableFrom": "time_entry", 2106 + "tableTo": "task", 2107 + "columnsFrom": ["task_id"], 2108 + "columnsTo": ["id"], 2109 + "onDelete": "cascade", 2110 + "onUpdate": "cascade" 2111 + }, 2112 + "time_entry_user_id_user_id_fk": { 2113 + "name": "time_entry_user_id_user_id_fk", 2114 + "tableFrom": "time_entry", 2115 + "tableTo": "user", 2116 + "columnsFrom": ["user_id"], 2117 + "columnsTo": ["id"], 2118 + "onDelete": "cascade", 2119 + "onUpdate": "cascade" 2120 + } 2121 + }, 2122 + "compositePrimaryKeys": {}, 2123 + "uniqueConstraints": {}, 2124 + "policies": {}, 2125 + "checkConstraints": {}, 2126 + "isRLSEnabled": false 2127 + }, 2128 + "public.user": { 2129 + "name": "user", 2130 + "schema": "", 2131 + "columns": { 2132 + "id": { 2133 + "name": "id", 2134 + "type": "text", 2135 + "primaryKey": true, 2136 + "notNull": true 2137 + }, 2138 + "name": { 2139 + "name": "name", 2140 + "type": "text", 2141 + "primaryKey": false, 2142 + "notNull": true 2143 + }, 2144 + "email": { 2145 + "name": "email", 2146 + "type": "text", 2147 + "primaryKey": false, 2148 + "notNull": true 2149 + }, 2150 + "email_verified": { 2151 + "name": "email_verified", 2152 + "type": "boolean", 2153 + "primaryKey": false, 2154 + "notNull": true 2155 + }, 2156 + "image": { 2157 + "name": "image", 2158 + "type": "text", 2159 + "primaryKey": false, 2160 + "notNull": false 2161 + }, 2162 + "locale": { 2163 + "name": "locale", 2164 + "type": "text", 2165 + "primaryKey": false, 2166 + "notNull": false 2167 + }, 2168 + "created_at": { 2169 + "name": "created_at", 2170 + "type": "timestamp", 2171 + "primaryKey": false, 2172 + "notNull": true, 2173 + "default": "now()" 2174 + }, 2175 + "updated_at": { 2176 + "name": "updated_at", 2177 + "type": "timestamp", 2178 + "primaryKey": false, 2179 + "notNull": true, 2180 + "default": "now()" 2181 + }, 2182 + "is_anonymous": { 2183 + "name": "is_anonymous", 2184 + "type": "boolean", 2185 + "primaryKey": false, 2186 + "notNull": false, 2187 + "default": false 2188 + } 2189 + }, 2190 + "indexes": {}, 2191 + "foreignKeys": {}, 2192 + "compositePrimaryKeys": {}, 2193 + "uniqueConstraints": { 2194 + "user_email_unique": { 2195 + "name": "user_email_unique", 2196 + "nullsNotDistinct": false, 2197 + "columns": ["email"] 2198 + } 2199 + }, 2200 + "policies": {}, 2201 + "checkConstraints": {}, 2202 + "isRLSEnabled": false 2203 + }, 2204 + "public.user_notification_preference": { 2205 + "name": "user_notification_preference", 2206 + "schema": "", 2207 + "columns": { 2208 + "user_id": { 2209 + "name": "user_id", 2210 + "type": "text", 2211 + "primaryKey": true, 2212 + "notNull": true 2213 + }, 2214 + "email_enabled": { 2215 + "name": "email_enabled", 2216 + "type": "boolean", 2217 + "primaryKey": false, 2218 + "notNull": true, 2219 + "default": false 2220 + }, 2221 + "ntfy_enabled": { 2222 + "name": "ntfy_enabled", 2223 + "type": "boolean", 2224 + "primaryKey": false, 2225 + "notNull": true, 2226 + "default": false 2227 + }, 2228 + "ntfy_server_url": { 2229 + "name": "ntfy_server_url", 2230 + "type": "text", 2231 + "primaryKey": false, 2232 + "notNull": false 2233 + }, 2234 + "ntfy_topic": { 2235 + "name": "ntfy_topic", 2236 + "type": "text", 2237 + "primaryKey": false, 2238 + "notNull": false 2239 + }, 2240 + "ntfy_token": { 2241 + "name": "ntfy_token", 2242 + "type": "text", 2243 + "primaryKey": false, 2244 + "notNull": false 2245 + }, 2246 + "webhook_enabled": { 2247 + "name": "webhook_enabled", 2248 + "type": "boolean", 2249 + "primaryKey": false, 2250 + "notNull": true, 2251 + "default": false 2252 + }, 2253 + "webhook_url": { 2254 + "name": "webhook_url", 2255 + "type": "text", 2256 + "primaryKey": false, 2257 + "notNull": false 2258 + }, 2259 + "webhook_secret": { 2260 + "name": "webhook_secret", 2261 + "type": "text", 2262 + "primaryKey": false, 2263 + "notNull": false 2264 + }, 2265 + "created_at": { 2266 + "name": "created_at", 2267 + "type": "timestamp", 2268 + "primaryKey": false, 2269 + "notNull": true, 2270 + "default": "now()" 2271 + }, 2272 + "updated_at": { 2273 + "name": "updated_at", 2274 + "type": "timestamp", 2275 + "primaryKey": false, 2276 + "notNull": true, 2277 + "default": "now()" 2278 + } 2279 + }, 2280 + "indexes": {}, 2281 + "foreignKeys": { 2282 + "user_notification_preference_user_id_user_id_fk": { 2283 + "name": "user_notification_preference_user_id_user_id_fk", 2284 + "tableFrom": "user_notification_preference", 2285 + "tableTo": "user", 2286 + "columnsFrom": ["user_id"], 2287 + "columnsTo": ["id"], 2288 + "onDelete": "cascade", 2289 + "onUpdate": "cascade" 2290 + } 2291 + }, 2292 + "compositePrimaryKeys": {}, 2293 + "uniqueConstraints": {}, 2294 + "policies": {}, 2295 + "checkConstraints": {}, 2296 + "isRLSEnabled": false 2297 + }, 2298 + "public.user_notification_workspace_project": { 2299 + "name": "user_notification_workspace_project", 2300 + "schema": "", 2301 + "columns": { 2302 + "id": { 2303 + "name": "id", 2304 + "type": "text", 2305 + "primaryKey": true, 2306 + "notNull": true 2307 + }, 2308 + "workspace_rule_id": { 2309 + "name": "workspace_rule_id", 2310 + "type": "text", 2311 + "primaryKey": false, 2312 + "notNull": true 2313 + }, 2314 + "project_id": { 2315 + "name": "project_id", 2316 + "type": "text", 2317 + "primaryKey": false, 2318 + "notNull": true 2319 + }, 2320 + "created_at": { 2321 + "name": "created_at", 2322 + "type": "timestamp", 2323 + "primaryKey": false, 2324 + "notNull": true, 2325 + "default": "now()" 2326 + } 2327 + }, 2328 + "indexes": { 2329 + "user_notification_workspace_project_ruleId_idx": { 2330 + "name": "user_notification_workspace_project_ruleId_idx", 2331 + "columns": [ 2332 + { 2333 + "expression": "workspace_rule_id", 2334 + "isExpression": false, 2335 + "asc": true, 2336 + "nulls": "last" 2337 + } 2338 + ], 2339 + "isUnique": false, 2340 + "concurrently": false, 2341 + "method": "btree", 2342 + "with": {} 2343 + }, 2344 + "user_notification_workspace_project_projectId_idx": { 2345 + "name": "user_notification_workspace_project_projectId_idx", 2346 + "columns": [ 2347 + { 2348 + "expression": "project_id", 2349 + "isExpression": false, 2350 + "asc": true, 2351 + "nulls": "last" 2352 + } 2353 + ], 2354 + "isUnique": false, 2355 + "concurrently": false, 2356 + "method": "btree", 2357 + "with": {} 2358 + } 2359 + }, 2360 + "foreignKeys": { 2361 + "user_notification_workspace_project_workspace_rule_id_user_notification_workspace_rule_id_fk": { 2362 + "name": "user_notification_workspace_project_workspace_rule_id_user_notification_workspace_rule_id_fk", 2363 + "tableFrom": "user_notification_workspace_project", 2364 + "tableTo": "user_notification_workspace_rule", 2365 + "columnsFrom": ["workspace_rule_id"], 2366 + "columnsTo": ["id"], 2367 + "onDelete": "cascade", 2368 + "onUpdate": "cascade" 2369 + }, 2370 + "user_notification_workspace_project_project_id_project_id_fk": { 2371 + "name": "user_notification_workspace_project_project_id_project_id_fk", 2372 + "tableFrom": "user_notification_workspace_project", 2373 + "tableTo": "project", 2374 + "columnsFrom": ["project_id"], 2375 + "columnsTo": ["id"], 2376 + "onDelete": "cascade", 2377 + "onUpdate": "cascade" 2378 + } 2379 + }, 2380 + "compositePrimaryKeys": {}, 2381 + "uniqueConstraints": { 2382 + "user_notification_workspace_project_rule_project_unique": { 2383 + "name": "user_notification_workspace_project_rule_project_unique", 2384 + "nullsNotDistinct": false, 2385 + "columns": ["workspace_rule_id", "project_id"] 2386 + } 2387 + }, 2388 + "policies": {}, 2389 + "checkConstraints": {}, 2390 + "isRLSEnabled": false 2391 + }, 2392 + "public.user_notification_workspace_rule": { 2393 + "name": "user_notification_workspace_rule", 2394 + "schema": "", 2395 + "columns": { 2396 + "id": { 2397 + "name": "id", 2398 + "type": "text", 2399 + "primaryKey": true, 2400 + "notNull": true 2401 + }, 2402 + "user_id": { 2403 + "name": "user_id", 2404 + "type": "text", 2405 + "primaryKey": false, 2406 + "notNull": true 2407 + }, 2408 + "workspace_id": { 2409 + "name": "workspace_id", 2410 + "type": "text", 2411 + "primaryKey": false, 2412 + "notNull": true 2413 + }, 2414 + "is_active": { 2415 + "name": "is_active", 2416 + "type": "boolean", 2417 + "primaryKey": false, 2418 + "notNull": true, 2419 + "default": true 2420 + }, 2421 + "email_enabled": { 2422 + "name": "email_enabled", 2423 + "type": "boolean", 2424 + "primaryKey": false, 2425 + "notNull": true, 2426 + "default": false 2427 + }, 2428 + "ntfy_enabled": { 2429 + "name": "ntfy_enabled", 2430 + "type": "boolean", 2431 + "primaryKey": false, 2432 + "notNull": true, 2433 + "default": false 2434 + }, 2435 + "webhook_enabled": { 2436 + "name": "webhook_enabled", 2437 + "type": "boolean", 2438 + "primaryKey": false, 2439 + "notNull": true, 2440 + "default": false 2441 + }, 2442 + "project_mode": { 2443 + "name": "project_mode", 2444 + "type": "text", 2445 + "primaryKey": false, 2446 + "notNull": true, 2447 + "default": "'all'" 2448 + }, 2449 + "created_at": { 2450 + "name": "created_at", 2451 + "type": "timestamp", 2452 + "primaryKey": false, 2453 + "notNull": true, 2454 + "default": "now()" 2455 + }, 2456 + "updated_at": { 2457 + "name": "updated_at", 2458 + "type": "timestamp", 2459 + "primaryKey": false, 2460 + "notNull": true, 2461 + "default": "now()" 2462 + } 2463 + }, 2464 + "indexes": { 2465 + "user_notification_workspace_rule_userId_idx": { 2466 + "name": "user_notification_workspace_rule_userId_idx", 2467 + "columns": [ 2468 + { 2469 + "expression": "user_id", 2470 + "isExpression": false, 2471 + "asc": true, 2472 + "nulls": "last" 2473 + } 2474 + ], 2475 + "isUnique": false, 2476 + "concurrently": false, 2477 + "method": "btree", 2478 + "with": {} 2479 + }, 2480 + "user_notification_workspace_rule_workspaceId_idx": { 2481 + "name": "user_notification_workspace_rule_workspaceId_idx", 2482 + "columns": [ 2483 + { 2484 + "expression": "workspace_id", 2485 + "isExpression": false, 2486 + "asc": true, 2487 + "nulls": "last" 2488 + } 2489 + ], 2490 + "isUnique": false, 2491 + "concurrently": false, 2492 + "method": "btree", 2493 + "with": {} 2494 + } 2495 + }, 2496 + "foreignKeys": { 2497 + "user_notification_workspace_rule_user_id_user_id_fk": { 2498 + "name": "user_notification_workspace_rule_user_id_user_id_fk", 2499 + "tableFrom": "user_notification_workspace_rule", 2500 + "tableTo": "user", 2501 + "columnsFrom": ["user_id"], 2502 + "columnsTo": ["id"], 2503 + "onDelete": "cascade", 2504 + "onUpdate": "cascade" 2505 + }, 2506 + "user_notification_workspace_rule_workspace_id_workspace_id_fk": { 2507 + "name": "user_notification_workspace_rule_workspace_id_workspace_id_fk", 2508 + "tableFrom": "user_notification_workspace_rule", 2509 + "tableTo": "workspace", 2510 + "columnsFrom": ["workspace_id"], 2511 + "columnsTo": ["id"], 2512 + "onDelete": "cascade", 2513 + "onUpdate": "cascade" 2514 + } 2515 + }, 2516 + "compositePrimaryKeys": {}, 2517 + "uniqueConstraints": { 2518 + "user_notification_workspace_rule_user_workspace_unique": { 2519 + "name": "user_notification_workspace_rule_user_workspace_unique", 2520 + "nullsNotDistinct": false, 2521 + "columns": ["user_id", "workspace_id"] 2522 + } 2523 + }, 2524 + "policies": {}, 2525 + "checkConstraints": {}, 2526 + "isRLSEnabled": false 2527 + }, 2528 + "public.verification": { 2529 + "name": "verification", 2530 + "schema": "", 2531 + "columns": { 2532 + "id": { 2533 + "name": "id", 2534 + "type": "text", 2535 + "primaryKey": true, 2536 + "notNull": true 2537 + }, 2538 + "identifier": { 2539 + "name": "identifier", 2540 + "type": "text", 2541 + "primaryKey": false, 2542 + "notNull": true 2543 + }, 2544 + "value": { 2545 + "name": "value", 2546 + "type": "text", 2547 + "primaryKey": false, 2548 + "notNull": true 2549 + }, 2550 + "expires_at": { 2551 + "name": "expires_at", 2552 + "type": "timestamp", 2553 + "primaryKey": false, 2554 + "notNull": true 2555 + }, 2556 + "created_at": { 2557 + "name": "created_at", 2558 + "type": "timestamp", 2559 + "primaryKey": false, 2560 + "notNull": true, 2561 + "default": "now()" 2562 + }, 2563 + "updated_at": { 2564 + "name": "updated_at", 2565 + "type": "timestamp", 2566 + "primaryKey": false, 2567 + "notNull": true, 2568 + "default": "now()" 2569 + } 2570 + }, 2571 + "indexes": { 2572 + "verification_identifier_idx": { 2573 + "name": "verification_identifier_idx", 2574 + "columns": [ 2575 + { 2576 + "expression": "identifier", 2577 + "isExpression": false, 2578 + "asc": true, 2579 + "nulls": "last" 2580 + } 2581 + ], 2582 + "isUnique": false, 2583 + "concurrently": false, 2584 + "method": "btree", 2585 + "with": {} 2586 + } 2587 + }, 2588 + "foreignKeys": {}, 2589 + "compositePrimaryKeys": {}, 2590 + "uniqueConstraints": {}, 2591 + "policies": {}, 2592 + "checkConstraints": {}, 2593 + "isRLSEnabled": false 2594 + }, 2595 + "public.workflow_rule": { 2596 + "name": "workflow_rule", 2597 + "schema": "", 2598 + "columns": { 2599 + "id": { 2600 + "name": "id", 2601 + "type": "text", 2602 + "primaryKey": true, 2603 + "notNull": true 2604 + }, 2605 + "project_id": { 2606 + "name": "project_id", 2607 + "type": "text", 2608 + "primaryKey": false, 2609 + "notNull": true 2610 + }, 2611 + "integration_type": { 2612 + "name": "integration_type", 2613 + "type": "text", 2614 + "primaryKey": false, 2615 + "notNull": true 2616 + }, 2617 + "event_type": { 2618 + "name": "event_type", 2619 + "type": "text", 2620 + "primaryKey": false, 2621 + "notNull": true 2622 + }, 2623 + "column_id": { 2624 + "name": "column_id", 2625 + "type": "text", 2626 + "primaryKey": false, 2627 + "notNull": true 2628 + }, 2629 + "created_at": { 2630 + "name": "created_at", 2631 + "type": "timestamp", 2632 + "primaryKey": false, 2633 + "notNull": true, 2634 + "default": "now()" 2635 + }, 2636 + "updated_at": { 2637 + "name": "updated_at", 2638 + "type": "timestamp", 2639 + "primaryKey": false, 2640 + "notNull": true, 2641 + "default": "now()" 2642 + } 2643 + }, 2644 + "indexes": { 2645 + "workflow_rule_projectId_idx": { 2646 + "name": "workflow_rule_projectId_idx", 2647 + "columns": [ 2648 + { 2649 + "expression": "project_id", 2650 + "isExpression": false, 2651 + "asc": true, 2652 + "nulls": "last" 2653 + } 2654 + ], 2655 + "isUnique": false, 2656 + "concurrently": false, 2657 + "method": "btree", 2658 + "with": {} 2659 + } 2660 + }, 2661 + "foreignKeys": { 2662 + "workflow_rule_project_id_project_id_fk": { 2663 + "name": "workflow_rule_project_id_project_id_fk", 2664 + "tableFrom": "workflow_rule", 2665 + "tableTo": "project", 2666 + "columnsFrom": ["project_id"], 2667 + "columnsTo": ["id"], 2668 + "onDelete": "cascade", 2669 + "onUpdate": "cascade" 2670 + }, 2671 + "workflow_rule_column_id_column_id_fk": { 2672 + "name": "workflow_rule_column_id_column_id_fk", 2673 + "tableFrom": "workflow_rule", 2674 + "tableTo": "column", 2675 + "columnsFrom": ["column_id"], 2676 + "columnsTo": ["id"], 2677 + "onDelete": "cascade", 2678 + "onUpdate": "cascade" 2679 + } 2680 + }, 2681 + "compositePrimaryKeys": {}, 2682 + "uniqueConstraints": {}, 2683 + "policies": {}, 2684 + "checkConstraints": {}, 2685 + "isRLSEnabled": false 2686 + }, 2687 + "public.workspace": { 2688 + "name": "workspace", 2689 + "schema": "", 2690 + "columns": { 2691 + "id": { 2692 + "name": "id", 2693 + "type": "text", 2694 + "primaryKey": true, 2695 + "notNull": true 2696 + }, 2697 + "name": { 2698 + "name": "name", 2699 + "type": "text", 2700 + "primaryKey": false, 2701 + "notNull": true 2702 + }, 2703 + "slug": { 2704 + "name": "slug", 2705 + "type": "text", 2706 + "primaryKey": false, 2707 + "notNull": true 2708 + }, 2709 + "logo": { 2710 + "name": "logo", 2711 + "type": "text", 2712 + "primaryKey": false, 2713 + "notNull": false 2714 + }, 2715 + "metadata": { 2716 + "name": "metadata", 2717 + "type": "text", 2718 + "primaryKey": false, 2719 + "notNull": false 2720 + }, 2721 + "description": { 2722 + "name": "description", 2723 + "type": "text", 2724 + "primaryKey": false, 2725 + "notNull": false 2726 + }, 2727 + "created_at": { 2728 + "name": "created_at", 2729 + "type": "timestamp", 2730 + "primaryKey": false, 2731 + "notNull": true 2732 + } 2733 + }, 2734 + "indexes": {}, 2735 + "foreignKeys": {}, 2736 + "compositePrimaryKeys": {}, 2737 + "uniqueConstraints": { 2738 + "workspace_slug_unique": { 2739 + "name": "workspace_slug_unique", 2740 + "nullsNotDistinct": false, 2741 + "columns": ["slug"] 2742 + } 2743 + }, 2744 + "policies": {}, 2745 + "checkConstraints": {}, 2746 + "isRLSEnabled": false 2747 + }, 2748 + "public.workspace_member": { 2749 + "name": "workspace_member", 2750 + "schema": "", 2751 + "columns": { 2752 + "id": { 2753 + "name": "id", 2754 + "type": "text", 2755 + "primaryKey": true, 2756 + "notNull": true 2757 + }, 2758 + "workspace_id": { 2759 + "name": "workspace_id", 2760 + "type": "text", 2761 + "primaryKey": false, 2762 + "notNull": true 2763 + }, 2764 + "user_id": { 2765 + "name": "user_id", 2766 + "type": "text", 2767 + "primaryKey": false, 2768 + "notNull": true 2769 + }, 2770 + "role": { 2771 + "name": "role", 2772 + "type": "text", 2773 + "primaryKey": false, 2774 + "notNull": true, 2775 + "default": "'member'" 2776 + }, 2777 + "joined_at": { 2778 + "name": "joined_at", 2779 + "type": "timestamp", 2780 + "primaryKey": false, 2781 + "notNull": true 2782 + } 2783 + }, 2784 + "indexes": { 2785 + "workspace_member_workspaceId_idx": { 2786 + "name": "workspace_member_workspaceId_idx", 2787 + "columns": [ 2788 + { 2789 + "expression": "workspace_id", 2790 + "isExpression": false, 2791 + "asc": true, 2792 + "nulls": "last" 2793 + } 2794 + ], 2795 + "isUnique": false, 2796 + "concurrently": false, 2797 + "method": "btree", 2798 + "with": {} 2799 + }, 2800 + "workspace_member_userId_idx": { 2801 + "name": "workspace_member_userId_idx", 2802 + "columns": [ 2803 + { 2804 + "expression": "user_id", 2805 + "isExpression": false, 2806 + "asc": true, 2807 + "nulls": "last" 2808 + } 2809 + ], 2810 + "isUnique": false, 2811 + "concurrently": false, 2812 + "method": "btree", 2813 + "with": {} 2814 + } 2815 + }, 2816 + "foreignKeys": { 2817 + "workspace_member_workspace_id_workspace_id_fk": { 2818 + "name": "workspace_member_workspace_id_workspace_id_fk", 2819 + "tableFrom": "workspace_member", 2820 + "tableTo": "workspace", 2821 + "columnsFrom": ["workspace_id"], 2822 + "columnsTo": ["id"], 2823 + "onDelete": "cascade", 2824 + "onUpdate": "no action" 2825 + }, 2826 + "workspace_member_user_id_user_id_fk": { 2827 + "name": "workspace_member_user_id_user_id_fk", 2828 + "tableFrom": "workspace_member", 2829 + "tableTo": "user", 2830 + "columnsFrom": ["user_id"], 2831 + "columnsTo": ["id"], 2832 + "onDelete": "cascade", 2833 + "onUpdate": "no action" 2834 + } 2835 + }, 2836 + "compositePrimaryKeys": {}, 2837 + "uniqueConstraints": {}, 2838 + "policies": {}, 2839 + "checkConstraints": {}, 2840 + "isRLSEnabled": false 2841 + } 2842 + }, 2843 + "enums": {}, 2844 + "schemas": {}, 2845 + "sequences": {}, 2846 + "roles": {}, 2847 + "policies": {}, 2848 + "views": {}, 2849 + "_meta": { 2850 + "columns": {}, 2851 + "schemas": {}, 2852 + "tables": {} 2853 + } 2854 + }
+7
apps/api/drizzle/meta/_journal.json
··· 141 141 "when": 1774823200346, 142 142 "tag": "0019_sloppy_meteorite", 143 143 "breakpoints": true 144 + }, 145 + { 146 + "idx": 20, 147 + "version": "7", 148 + "when": 1775035770523, 149 + "tag": "0020_careful_shiver_man", 150 + "breakpoints": true 144 151 } 145 152 ] 146 153 }
+12
apps/api/src/database/index.ts
··· 22 22 teamTableRelations, 23 23 timeEntryTableRelations, 24 24 userTableRelations, 25 + userNotificationPreferenceTableRelations, 26 + userNotificationWorkspaceProjectTableRelations, 27 + userNotificationWorkspaceRuleTableRelations, 25 28 verificationTableRelations, 26 29 workflowRuleTableRelations, 27 30 workspaceTableRelations, ··· 48 51 teamTable, 49 52 timeEntryTable, 50 53 userTable, 54 + userNotificationPreferenceTable, 55 + userNotificationWorkspaceProjectTable, 56 + userNotificationWorkspaceRuleTable, 51 57 verificationTable, 52 58 workflowRuleTable, 53 59 workspaceTable, ··· 83 89 teamTable, 84 90 timeEntryTable, 85 91 userTable, 92 + userNotificationPreferenceTable, 93 + userNotificationWorkspaceProjectTable, 94 + userNotificationWorkspaceRuleTable, 86 95 verificationTable, 87 96 workflowRuleTable, 88 97 workspaceTable, ··· 107 116 teamTableRelations, 108 117 timeEntryTableRelations, 109 118 userTableRelations, 119 + userNotificationPreferenceTableRelations, 120 + userNotificationWorkspaceProjectTableRelations, 121 + userNotificationWorkspaceRuleTableRelations, 110 122 verificationTableRelations, 111 123 workflowRuleTableRelations, 112 124 workspaceTableRelations,
+46
apps/api/src/database/relations.ts
··· 20 20 teamTable, 21 21 timeEntryTable, 22 22 userTable, 23 + userNotificationPreferenceTable, 24 + userNotificationWorkspaceProjectTable, 25 + userNotificationWorkspaceRuleTable, 23 26 verificationTable, 24 27 workflowRuleTable, 25 28 workspaceTable, ··· 38 41 comments: many(commentTable), 39 42 assets: many(assetTable), 40 43 notifications: many(notificationTable), 44 + notificationPreference: many(userNotificationPreferenceTable), 45 + notificationWorkspaceRules: many(userNotificationWorkspaceRuleTable), 41 46 sentInvitations: many(invitationTable), 42 47 apikeys: many(apikeyTable), 43 48 })); ··· 69 74 projects: many(projectTable), 70 75 assets: many(assetTable), 71 76 invitations: many(invitationTable), 77 + notificationWorkspaceRules: many(userNotificationWorkspaceRuleTable), 72 78 }), 73 79 ); 74 80 ··· 99 105 workflowRules: many(workflowRuleTable), 100 106 githubIntegration: many(githubIntegrationTable), 101 107 integrations: many(integrationTable), 108 + notificationWorkspaceProjects: many(userNotificationWorkspaceProjectTable), 102 109 }), 103 110 ); 104 111 ··· 206 213 user: one(userTable, { 207 214 fields: [notificationTable.userId], 208 215 references: [userTable.id], 216 + }), 217 + }), 218 + ); 219 + 220 + export const userNotificationPreferenceTableRelations = relations( 221 + userNotificationPreferenceTable, 222 + ({ one }) => ({ 223 + user: one(userTable, { 224 + fields: [userNotificationPreferenceTable.userId], 225 + references: [userTable.id], 226 + }), 227 + }), 228 + ); 229 + 230 + export const userNotificationWorkspaceRuleTableRelations = relations( 231 + userNotificationWorkspaceRuleTable, 232 + ({ one, many }) => ({ 233 + user: one(userTable, { 234 + fields: [userNotificationWorkspaceRuleTable.userId], 235 + references: [userTable.id], 236 + }), 237 + workspace: one(workspaceTable, { 238 + fields: [userNotificationWorkspaceRuleTable.workspaceId], 239 + references: [workspaceTable.id], 240 + }), 241 + selectedProjects: many(userNotificationWorkspaceProjectTable), 242 + }), 243 + ); 244 + 245 + export const userNotificationWorkspaceProjectTableRelations = relations( 246 + userNotificationWorkspaceProjectTable, 247 + ({ one }) => ({ 248 + workspaceRule: one(userNotificationWorkspaceRuleTable, { 249 + fields: [userNotificationWorkspaceProjectTable.workspaceRuleId], 250 + references: [userNotificationWorkspaceRuleTable.id], 251 + }), 252 + project: one(projectTable, { 253 + fields: [userNotificationWorkspaceProjectTable.projectId], 254 + references: [projectTable.id], 209 255 }), 210 256 }), 211 257 );
+97
apps/api/src/database/schema.ts
··· 429 429 .notNull(), 430 430 }); 431 431 432 + export const userNotificationPreferenceTable = pgTable("user_notification_preference", { 433 + userId: text("user_id") 434 + .primaryKey() 435 + .references(() => userTable.id, { 436 + onDelete: "cascade", 437 + onUpdate: "cascade", 438 + }), 439 + emailEnabled: boolean("email_enabled").default(false).notNull(), 440 + ntfyEnabled: boolean("ntfy_enabled").default(false).notNull(), 441 + ntfyServerUrl: text("ntfy_server_url"), 442 + ntfyTopic: text("ntfy_topic"), 443 + ntfyToken: text("ntfy_token"), 444 + webhookEnabled: boolean("webhook_enabled").default(false).notNull(), 445 + webhookUrl: text("webhook_url"), 446 + webhookSecret: text("webhook_secret"), 447 + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 448 + updatedAt: timestamp("updated_at", { mode: "date" }) 449 + .defaultNow() 450 + .$onUpdate(() => new Date()) 451 + .notNull(), 452 + }); 453 + 454 + export const userNotificationWorkspaceRuleTable = pgTable( 455 + "user_notification_workspace_rule", 456 + { 457 + id: text("id") 458 + .$defaultFn(() => createId()) 459 + .primaryKey(), 460 + userId: text("user_id") 461 + .notNull() 462 + .references(() => userTable.id, { 463 + onDelete: "cascade", 464 + onUpdate: "cascade", 465 + }), 466 + workspaceId: text("workspace_id") 467 + .notNull() 468 + .references(() => workspaceTable.id, { 469 + onDelete: "cascade", 470 + onUpdate: "cascade", 471 + }), 472 + isActive: boolean("is_active").default(true).notNull(), 473 + emailEnabled: boolean("email_enabled").default(false).notNull(), 474 + ntfyEnabled: boolean("ntfy_enabled").default(false).notNull(), 475 + webhookEnabled: boolean("webhook_enabled").default(false).notNull(), 476 + projectMode: text("project_mode").default("all").notNull(), 477 + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 478 + updatedAt: timestamp("updated_at", { mode: "date" }) 479 + .defaultNow() 480 + .$onUpdate(() => new Date()) 481 + .notNull(), 482 + }, 483 + (table) => [ 484 + index("user_notification_workspace_rule_userId_idx").on(table.userId), 485 + index("user_notification_workspace_rule_workspaceId_idx").on( 486 + table.workspaceId, 487 + ), 488 + unique("user_notification_workspace_rule_user_workspace_unique").on( 489 + table.userId, 490 + table.workspaceId, 491 + ), 492 + ], 493 + ); 494 + 495 + export const userNotificationWorkspaceProjectTable = pgTable( 496 + "user_notification_workspace_project", 497 + { 498 + id: text("id") 499 + .$defaultFn(() => createId()) 500 + .primaryKey(), 501 + workspaceRuleId: text("workspace_rule_id") 502 + .notNull() 503 + .references(() => userNotificationWorkspaceRuleTable.id, { 504 + onDelete: "cascade", 505 + onUpdate: "cascade", 506 + }), 507 + projectId: text("project_id") 508 + .notNull() 509 + .references(() => projectTable.id, { 510 + onDelete: "cascade", 511 + onUpdate: "cascade", 512 + }), 513 + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 514 + }, 515 + (table) => [ 516 + index("user_notification_workspace_project_ruleId_idx").on( 517 + table.workspaceRuleId, 518 + ), 519 + index("user_notification_workspace_project_projectId_idx").on( 520 + table.projectId, 521 + ), 522 + unique("user_notification_workspace_project_rule_project_unique").on( 523 + table.workspaceRuleId, 524 + table.projectId, 525 + ), 526 + ], 527 + ); 528 + 432 529 export const githubIntegrationTable = pgTable("github_integration", { 433 530 id: text("id") 434 531 .$defaultFn(() => createId())
+6
apps/api/src/index.ts
··· 28 28 import label from "./label"; 29 29 import { migrateColumns } from "./migrations/column-migration"; 30 30 import notification from "./notification"; 31 + import notificationPreferences from "./notification-preferences"; 31 32 import { initializePlugins } from "./plugins"; 32 33 import { migrateGitHubIntegration } from "./plugins/github/migration"; 33 34 import project from "./project"; ··· 393 394 const timeEntryApi = api.route("/time-entry", timeEntry); 394 395 const labelApi = api.route("/label", label); 395 396 const notificationApi = api.route("/notification", notification); 397 + const notificationPreferencesApi = api.route( 398 + "/notification-preferences", 399 + notificationPreferences, 400 + ); 396 401 const searchApi = api.route("/search", search); 397 402 const githubIntegrationApi = api.route( 398 403 "/github-integration", ··· 459 464 | typeof timeEntryApi 460 465 | typeof labelApi 461 466 | typeof notificationApi 467 + | typeof notificationPreferencesApi 462 468 | typeof searchApi 463 469 | typeof githubIntegrationApi 464 470 | typeof genericWebhookIntegrationApi
+437
apps/api/src/notification-preferences/delivery.ts
··· 1 + import { createHmac } from "node:crypto"; 2 + import { sendNotificationEmail } from "@kaneo/email"; 3 + import { and, eq, inArray } from "drizzle-orm"; 4 + import db from "../database"; 5 + import { 6 + notificationTable, 7 + projectTable, 8 + taskTable, 9 + userNotificationPreferenceTable, 10 + userNotificationWorkspaceProjectTable, 11 + userNotificationWorkspaceRuleTable, 12 + userTable, 13 + workspaceTable, 14 + } from "../database/schema"; 15 + import { assertPublicWebhookDestination } from "../plugins/generic-webhook/config"; 16 + 17 + type ResolvedNotificationContext = { 18 + workspaceId: string; 19 + workspaceName: string; 20 + projectId: string | null; 21 + projectName: string | null; 22 + taskId: string | null; 23 + taskTitle: string | null; 24 + taskUrl: string | null; 25 + }; 26 + 27 + type DeliveryContent = { 28 + title: string; 29 + body: string; 30 + }; 31 + 32 + function buildTaskUrl(workspaceId: string, projectId: string, taskId: string) { 33 + const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173"; 34 + return `${clientUrl}/dashboard/workspace/${workspaceId}/project/${projectId}/task/${taskId}`; 35 + } 36 + 37 + function getStringValue( 38 + data: Record<string, unknown> | null | undefined, 39 + key: string, 40 + ) { 41 + const value = data?.[key]; 42 + return typeof value === "string" ? value : null; 43 + } 44 + 45 + function buildDeliveryContent(notification: { 46 + type: string; 47 + content: string | null; 48 + title: string | null; 49 + eventData: Record<string, unknown> | null; 50 + }): DeliveryContent { 51 + if (notification.title && notification.content) { 52 + return { 53 + title: notification.title, 54 + body: notification.content, 55 + }; 56 + } 57 + 58 + switch (notification.type) { 59 + case "task_created": { 60 + const taskTitle = getStringValue(notification.eventData, "taskTitle"); 61 + return { 62 + title: "New task created", 63 + body: taskTitle 64 + ? `A new task was created: ${taskTitle}` 65 + : "A new task was created in Kaneo.", 66 + }; 67 + } 68 + case "workspace_created": { 69 + const workspaceName = getStringValue( 70 + notification.eventData, 71 + "workspaceName", 72 + ); 73 + return { 74 + title: "Workspace created", 75 + body: workspaceName 76 + ? `Workspace created: ${workspaceName}` 77 + : "A new workspace was created in Kaneo.", 78 + }; 79 + } 80 + case "task_status_changed": { 81 + const taskTitle = getStringValue(notification.eventData, "taskTitle"); 82 + const oldStatus = getStringValue(notification.eventData, "oldStatus"); 83 + const newStatus = getStringValue(notification.eventData, "newStatus"); 84 + return { 85 + title: "Task status changed", 86 + body: 87 + taskTitle && oldStatus && newStatus 88 + ? `${taskTitle} moved from ${oldStatus} to ${newStatus}.` 89 + : "A task status changed in Kaneo.", 90 + }; 91 + } 92 + case "task_assignee_changed": { 93 + const taskTitle = getStringValue(notification.eventData, "taskTitle"); 94 + return { 95 + title: "Task assigned to you", 96 + body: taskTitle 97 + ? `You were assigned to ${taskTitle}.` 98 + : "A task was assigned to you in Kaneo.", 99 + }; 100 + } 101 + case "time_entry_created": { 102 + const taskTitle = getStringValue(notification.eventData, "taskTitle"); 103 + return { 104 + title: "Time entry created", 105 + body: taskTitle 106 + ? `A time entry was created for ${taskTitle}.` 107 + : "A time entry was created in Kaneo.", 108 + }; 109 + } 110 + default: 111 + return { 112 + title: notification.title ?? "New Kaneo notification", 113 + body: notification.content ?? "You have a new notification in Kaneo.", 114 + }; 115 + } 116 + } 117 + 118 + async function resolveNotificationContext(notification: { 119 + resourceType: string | null; 120 + resourceId: string | null; 121 + }): Promise<ResolvedNotificationContext | null> { 122 + if (!notification.resourceType || !notification.resourceId) { 123 + return null; 124 + } 125 + 126 + if (notification.resourceType === "task") { 127 + const [task] = await db 128 + .select({ 129 + taskId: taskTable.id, 130 + taskTitle: taskTable.title, 131 + projectId: projectTable.id, 132 + projectName: projectTable.name, 133 + workspaceId: workspaceTable.id, 134 + workspaceName: workspaceTable.name, 135 + }) 136 + .from(taskTable) 137 + .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) 138 + .innerJoin( 139 + workspaceTable, 140 + eq(projectTable.workspaceId, workspaceTable.id), 141 + ) 142 + .where(eq(taskTable.id, notification.resourceId)) 143 + .limit(1); 144 + 145 + if (!task) { 146 + return null; 147 + } 148 + 149 + return { 150 + workspaceId: task.workspaceId, 151 + workspaceName: task.workspaceName, 152 + projectId: task.projectId, 153 + projectName: task.projectName, 154 + taskId: task.taskId, 155 + taskTitle: task.taskTitle, 156 + taskUrl: buildTaskUrl(task.workspaceId, task.projectId, task.taskId), 157 + }; 158 + } 159 + 160 + if (notification.resourceType === "workspace") { 161 + const [workspace] = await db 162 + .select({ 163 + workspaceId: workspaceTable.id, 164 + workspaceName: workspaceTable.name, 165 + }) 166 + .from(workspaceTable) 167 + .where(eq(workspaceTable.id, notification.resourceId)) 168 + .limit(1); 169 + 170 + if (!workspace) { 171 + return null; 172 + } 173 + 174 + return { 175 + workspaceId: workspace.workspaceId, 176 + workspaceName: workspace.workspaceName, 177 + projectId: null, 178 + projectName: null, 179 + taskId: null, 180 + taskTitle: null, 181 + taskUrl: null, 182 + }; 183 + } 184 + 185 + return null; 186 + } 187 + 188 + async function sendNtfyNotification(input: { 189 + serverUrl: string; 190 + topic: string; 191 + token?: string | null; 192 + title: string; 193 + body: string; 194 + clickUrl?: string | null; 195 + }) { 196 + await assertPublicWebhookDestination(input.serverUrl); 197 + 198 + const response = await fetch( 199 + `${input.serverUrl.replace(/\/+$/, "")}/${encodeURIComponent(input.topic)}`, 200 + { 201 + method: "POST", 202 + headers: { 203 + ...(input.token ? { Authorization: `Bearer ${input.token}` } : {}), 204 + ...(input.clickUrl ? { Click: input.clickUrl } : {}), 205 + Title: input.title, 206 + }, 207 + body: input.body, 208 + }, 209 + ); 210 + 211 + if (!response.ok) { 212 + throw new Error( 213 + `ntfy delivery failed (${response.status}): ${await response.text()}`, 214 + ); 215 + } 216 + } 217 + 218 + async function sendWebhookNotification(input: { 219 + webhookUrl: string; 220 + secret?: string | null; 221 + payload: Record<string, unknown>; 222 + }) { 223 + await assertPublicWebhookDestination(input.webhookUrl); 224 + 225 + const body = JSON.stringify(input.payload); 226 + const headers: Record<string, string> = { 227 + "Content-Type": "application/json", 228 + }; 229 + 230 + if (input.secret) { 231 + headers["X-Kaneo-Signature"] = createHmac("sha256", input.secret) 232 + .update(body) 233 + .digest("hex"); 234 + } 235 + 236 + const response = await fetch(input.webhookUrl, { 237 + method: "POST", 238 + headers, 239 + body, 240 + }); 241 + 242 + if (!response.ok) { 243 + throw new Error( 244 + `Webhook delivery failed (${response.status}): ${await response.text()}`, 245 + ); 246 + } 247 + } 248 + 249 + export async function deliverNotification( 250 + notificationId: string, 251 + ): Promise<void> { 252 + const notification = await db.query.notificationTable.findFirst({ 253 + where: eq(notificationTable.id, notificationId), 254 + }); 255 + 256 + if (!notification) { 257 + return; 258 + } 259 + 260 + const context = await resolveNotificationContext(notification); 261 + if (!context) { 262 + return; 263 + } 264 + 265 + const [user] = await db 266 + .select({ 267 + email: userTable.email, 268 + name: userTable.name, 269 + locale: userTable.locale, 270 + }) 271 + .from(userTable) 272 + .where(eq(userTable.id, notification.userId)) 273 + .limit(1); 274 + 275 + if (!user) { 276 + return; 277 + } 278 + 279 + const preference = await db.query.userNotificationPreferenceTable.findFirst({ 280 + where: eq(userNotificationPreferenceTable.userId, notification.userId), 281 + }); 282 + 283 + if (!preference) { 284 + return; 285 + } 286 + 287 + const rule = await db.query.userNotificationWorkspaceRuleTable.findFirst({ 288 + where: and( 289 + eq(userNotificationWorkspaceRuleTable.userId, notification.userId), 290 + eq(userNotificationWorkspaceRuleTable.workspaceId, context.workspaceId), 291 + ), 292 + with: { 293 + selectedProjects: true, 294 + }, 295 + }); 296 + 297 + if (!rule?.isActive) { 298 + return; 299 + } 300 + 301 + if ( 302 + context.projectId && 303 + rule.projectMode === "selected" && 304 + !rule.selectedProjects.some( 305 + (project) => project.projectId === context.projectId, 306 + ) 307 + ) { 308 + return; 309 + } 310 + 311 + const content = buildDeliveryContent({ 312 + type: notification.type, 313 + title: notification.title ?? null, 314 + content: notification.content ?? null, 315 + eventData: 316 + notification.eventData && typeof notification.eventData === "object" 317 + ? (notification.eventData as Record<string, unknown>) 318 + : null, 319 + }); 320 + 321 + const webhookPayload = { 322 + notification: { 323 + id: notification.id, 324 + type: notification.type, 325 + title: content.title, 326 + content: content.body, 327 + createdAt: notification.createdAt, 328 + eventData: notification.eventData, 329 + resourceId: notification.resourceId, 330 + resourceType: notification.resourceType, 331 + }, 332 + workspace: { 333 + id: context.workspaceId, 334 + name: context.workspaceName, 335 + }, 336 + project: context.projectId 337 + ? { 338 + id: context.projectId, 339 + name: context.projectName, 340 + } 341 + : null, 342 + task: context.taskId 343 + ? { 344 + id: context.taskId, 345 + title: context.taskTitle, 346 + url: context.taskUrl, 347 + } 348 + : null, 349 + user: { 350 + id: notification.userId, 351 + email: user.email, 352 + name: user.name, 353 + }, 354 + }; 355 + 356 + const deliveries: Array<Promise<void>> = []; 357 + 358 + if (preference.emailEnabled && rule.emailEnabled && user.email) { 359 + deliveries.push( 360 + sendNotificationEmail(user.email, content.title, { 361 + title: content.title, 362 + message: content.body, 363 + actionUrl: context.taskUrl, 364 + actionLabel: context.taskUrl ? "Open in Kaneo" : undefined, 365 + locale: user.locale ?? null, 366 + }), 367 + ); 368 + } 369 + 370 + if ( 371 + preference.ntfyEnabled && 372 + preference.ntfyServerUrl && 373 + preference.ntfyTopic && 374 + rule.ntfyEnabled 375 + ) { 376 + deliveries.push( 377 + sendNtfyNotification({ 378 + serverUrl: preference.ntfyServerUrl, 379 + topic: preference.ntfyTopic, 380 + token: preference.ntfyToken, 381 + title: content.title, 382 + body: content.body, 383 + clickUrl: context.taskUrl, 384 + }), 385 + ); 386 + } 387 + 388 + if ( 389 + preference.webhookEnabled && 390 + preference.webhookUrl && 391 + rule.webhookEnabled 392 + ) { 393 + deliveries.push( 394 + sendWebhookNotification({ 395 + webhookUrl: preference.webhookUrl, 396 + secret: preference.webhookSecret, 397 + payload: webhookPayload, 398 + }), 399 + ); 400 + } 401 + 402 + const results = await Promise.allSettled(deliveries); 403 + for (const result of results) { 404 + if (result.status === "rejected") { 405 + console.error("Notification delivery failed", { 406 + notificationId, 407 + error: result.reason, 408 + }); 409 + } 410 + } 411 + } 412 + 413 + export async function hasSelectedProjects( 414 + userId: string, 415 + workspaceId: string, 416 + projectIds: string[], 417 + ) { 418 + const rows = await db 419 + .select({ projectId: userNotificationWorkspaceProjectTable.projectId }) 420 + .from(userNotificationWorkspaceProjectTable) 421 + .innerJoin( 422 + userNotificationWorkspaceRuleTable, 423 + eq( 424 + userNotificationWorkspaceProjectTable.workspaceRuleId, 425 + userNotificationWorkspaceRuleTable.id, 426 + ), 427 + ) 428 + .where( 429 + and( 430 + eq(userNotificationWorkspaceRuleTable.userId, userId), 431 + eq(userNotificationWorkspaceRuleTable.workspaceId, workspaceId), 432 + inArray(userNotificationWorkspaceProjectTable.projectId, projectIds), 433 + ), 434 + ); 435 + 436 + return rows.length > 0; 437 + }
+153
apps/api/src/notification-preferences/index.ts
··· 1 + import { Hono } from "hono"; 2 + import { describeRoute, resolver, validator } from "hono-openapi"; 3 + import * as v from "valibot"; 4 + import { notificationPreferenceSchema } from "../schemas"; 5 + import { 6 + deleteWorkspaceRule, 7 + getNotificationPreferences, 8 + updateNotificationPreferences, 9 + upsertWorkspaceRule, 10 + } from "./service"; 11 + 12 + const workspaceRuleSchema = v.object({ 13 + isActive: v.boolean(), 14 + emailEnabled: v.boolean(), 15 + ntfyEnabled: v.boolean(), 16 + webhookEnabled: v.boolean(), 17 + projectMode: v.picklist(["all", "selected"] as const), 18 + selectedProjectIds: v.optional(v.array(v.string())), 19 + }); 20 + 21 + const notificationPreferences = new Hono<{ 22 + Variables: { 23 + userId: string; 24 + userEmail: string; 25 + }; 26 + }>(); 27 + 28 + notificationPreferences 29 + .get( 30 + "/", 31 + describeRoute({ 32 + operationId: "getNotificationPreferences", 33 + tags: ["Notification Preferences"], 34 + description: "Get notification delivery preferences for the current user", 35 + responses: { 36 + 200: { 37 + description: "Notification preferences", 38 + content: { 39 + "application/json": { 40 + schema: resolver(notificationPreferenceSchema), 41 + }, 42 + }, 43 + }, 44 + }, 45 + }), 46 + async (c) => { 47 + const userId = c.get("userId"); 48 + const userEmail = c.get("userEmail"); 49 + return c.json( 50 + await getNotificationPreferences(userId, userEmail || null), 51 + ); 52 + }, 53 + ) 54 + .put( 55 + "/", 56 + describeRoute({ 57 + operationId: "updateNotificationPreferences", 58 + tags: ["Notification Preferences"], 59 + description: "Update global notification delivery preferences", 60 + responses: { 61 + 200: { 62 + description: "Updated notification preferences", 63 + content: { 64 + "application/json": { 65 + schema: resolver(notificationPreferenceSchema), 66 + }, 67 + }, 68 + }, 69 + }, 70 + }), 71 + validator( 72 + "json", 73 + v.object({ 74 + emailEnabled: v.optional(v.boolean()), 75 + ntfyEnabled: v.optional(v.boolean()), 76 + ntfyServerUrl: v.optional(v.nullable(v.string())), 77 + ntfyTopic: v.optional(v.nullable(v.string())), 78 + ntfyToken: v.optional(v.nullable(v.string())), 79 + webhookEnabled: v.optional(v.boolean()), 80 + webhookUrl: v.optional(v.nullable(v.string())), 81 + webhookSecret: v.optional(v.nullable(v.string())), 82 + }), 83 + ), 84 + async (c) => { 85 + const userId = c.get("userId"); 86 + const userEmail = c.get("userEmail"); 87 + const body = c.req.valid("json"); 88 + 89 + return c.json( 90 + await updateNotificationPreferences(userId, userEmail || null, body), 91 + ); 92 + }, 93 + ) 94 + .put( 95 + "/workspaces/:workspaceId", 96 + describeRoute({ 97 + operationId: "upsertNotificationPreferenceWorkspaceRule", 98 + tags: ["Notification Preferences"], 99 + description: "Create or update a workspace notification rule", 100 + responses: { 101 + 200: { 102 + description: "Updated notification preferences", 103 + content: { 104 + "application/json": { 105 + schema: resolver(notificationPreferenceSchema), 106 + }, 107 + }, 108 + }, 109 + }, 110 + }), 111 + validator("param", v.object({ workspaceId: v.string() })), 112 + validator("json", workspaceRuleSchema), 113 + async (c) => { 114 + const userId = c.get("userId"); 115 + const userEmail = c.get("userEmail"); 116 + const { workspaceId } = c.req.valid("param"); 117 + const body = c.req.valid("json"); 118 + 119 + return c.json( 120 + await upsertWorkspaceRule(userId, workspaceId, userEmail || null, body), 121 + ); 122 + }, 123 + ) 124 + .delete( 125 + "/workspaces/:workspaceId", 126 + describeRoute({ 127 + operationId: "deleteNotificationPreferenceWorkspaceRule", 128 + tags: ["Notification Preferences"], 129 + description: "Delete a workspace notification rule", 130 + responses: { 131 + 200: { 132 + description: "Updated notification preferences", 133 + content: { 134 + "application/json": { 135 + schema: resolver(notificationPreferenceSchema), 136 + }, 137 + }, 138 + }, 139 + }, 140 + }), 141 + validator("param", v.object({ workspaceId: v.string() })), 142 + async (c) => { 143 + const userId = c.get("userId"); 144 + const userEmail = c.get("userEmail"); 145 + const { workspaceId } = c.req.valid("param"); 146 + 147 + return c.json( 148 + await deleteWorkspaceRule(userId, workspaceId, userEmail || null), 149 + ); 150 + }, 151 + ); 152 + 153 + export default notificationPreferences;
+448
apps/api/src/notification-preferences/service.ts
··· 1 + import { and, eq, inArray } from "drizzle-orm"; 2 + import { HTTPException } from "hono/http-exception"; 3 + import db from "../database"; 4 + import { 5 + projectTable, 6 + userNotificationPreferenceTable, 7 + userNotificationWorkspaceProjectTable, 8 + userNotificationWorkspaceRuleTable, 9 + workspaceUserTable, 10 + } from "../database/schema"; 11 + import { assertPublicWebhookDestination } from "../plugins/generic-webhook/config"; 12 + 13 + export type NotificationPreferenceProjectMode = "all" | "selected"; 14 + 15 + export type NotificationPreferenceResponse = { 16 + emailAddress: string | null; 17 + emailEnabled: boolean; 18 + ntfyEnabled: boolean; 19 + ntfyConfigured: boolean; 20 + ntfyServerUrl: string | null; 21 + ntfyTopic: string | null; 22 + ntfyTokenConfigured: boolean; 23 + maskedNtfyToken: string | null; 24 + webhookEnabled: boolean; 25 + webhookConfigured: boolean; 26 + webhookUrl: string | null; 27 + webhookSecretConfigured: boolean; 28 + maskedWebhookSecret: string | null; 29 + workspaces: Array<{ 30 + id: string; 31 + workspaceId: string; 32 + workspaceName: string; 33 + isActive: boolean; 34 + emailEnabled: boolean; 35 + ntfyEnabled: boolean; 36 + webhookEnabled: boolean; 37 + projectMode: NotificationPreferenceProjectMode; 38 + selectedProjectIds: string[]; 39 + createdAt: Date; 40 + updatedAt: Date; 41 + }>; 42 + createdAt: Date | null; 43 + updatedAt: Date | null; 44 + }; 45 + 46 + export type UpdateNotificationPreferenceInput = { 47 + emailEnabled?: boolean; 48 + ntfyEnabled?: boolean; 49 + ntfyServerUrl?: string | null; 50 + ntfyTopic?: string | null; 51 + ntfyToken?: string | null; 52 + webhookEnabled?: boolean; 53 + webhookUrl?: string | null; 54 + webhookSecret?: string | null; 55 + }; 56 + 57 + export type UpsertWorkspaceRuleInput = { 58 + isActive: boolean; 59 + emailEnabled: boolean; 60 + ntfyEnabled: boolean; 61 + webhookEnabled: boolean; 62 + projectMode: NotificationPreferenceProjectMode; 63 + selectedProjectIds?: string[]; 64 + }; 65 + 66 + function normalizeOptionalString(value: string | null | undefined) { 67 + if (typeof value !== "string") { 68 + return value === null ? null : undefined; 69 + } 70 + 71 + const trimmed = value.trim(); 72 + return trimmed.length > 0 ? trimmed : null; 73 + } 74 + 75 + function maskValue(value: string | undefined | null): string | null { 76 + if (!value) return null; 77 + return value.length > 8 ? `${value.slice(0, 4)}…${value.slice(-4)}` : "••••"; 78 + } 79 + 80 + async function assertWorkspaceMembership(userId: string, workspaceId: string) { 81 + const [membership] = await db 82 + .select({ workspaceId: workspaceUserTable.workspaceId }) 83 + .from(workspaceUserTable) 84 + .where( 85 + and( 86 + eq(workspaceUserTable.userId, userId), 87 + eq(workspaceUserTable.workspaceId, workspaceId), 88 + ), 89 + ) 90 + .limit(1); 91 + 92 + if (!membership) { 93 + throw new HTTPException(403, { 94 + message: "You don't have access to this workspace", 95 + }); 96 + } 97 + } 98 + 99 + export async function validateProjectSelection( 100 + workspaceId: string, 101 + selectedProjectIds: string[], 102 + ) { 103 + if (selectedProjectIds.length === 0) { 104 + throw new HTTPException(400, { 105 + message: "Select at least one project for selected project mode", 106 + }); 107 + } 108 + 109 + const projects = await db 110 + .select({ id: projectTable.id }) 111 + .from(projectTable) 112 + .where( 113 + and( 114 + eq(projectTable.workspaceId, workspaceId), 115 + inArray(projectTable.id, selectedProjectIds), 116 + ), 117 + ); 118 + 119 + if (projects.length !== selectedProjectIds.length) { 120 + throw new HTTPException(400, { 121 + message: "One or more selected projects are invalid", 122 + }); 123 + } 124 + } 125 + 126 + export async function getNotificationPreferences( 127 + userId: string, 128 + emailAddress: string | null, 129 + ): Promise<NotificationPreferenceResponse> { 130 + const preference = await db.query.userNotificationPreferenceTable.findFirst({ 131 + where: eq(userNotificationPreferenceTable.userId, userId), 132 + }); 133 + 134 + const rules = await db.query.userNotificationWorkspaceRuleTable.findMany({ 135 + where: eq(userNotificationWorkspaceRuleTable.userId, userId), 136 + with: { 137 + workspace: true, 138 + selectedProjects: true, 139 + }, 140 + orderBy: (table, { asc }) => [asc(table.createdAt)], 141 + }); 142 + 143 + return { 144 + emailAddress, 145 + emailEnabled: preference?.emailEnabled ?? false, 146 + ntfyEnabled: preference?.ntfyEnabled ?? false, 147 + ntfyConfigured: Boolean(preference?.ntfyServerUrl && preference?.ntfyTopic), 148 + ntfyServerUrl: preference?.ntfyServerUrl ?? null, 149 + ntfyTopic: preference?.ntfyTopic ?? null, 150 + ntfyTokenConfigured: Boolean(preference?.ntfyToken), 151 + maskedNtfyToken: maskValue(preference?.ntfyToken), 152 + webhookEnabled: preference?.webhookEnabled ?? false, 153 + webhookConfigured: Boolean(preference?.webhookUrl), 154 + webhookUrl: preference?.webhookUrl ?? null, 155 + webhookSecretConfigured: Boolean(preference?.webhookSecret), 156 + maskedWebhookSecret: maskValue(preference?.webhookSecret), 157 + workspaces: rules.map((rule) => ({ 158 + id: rule.id, 159 + workspaceId: rule.workspaceId, 160 + workspaceName: rule.workspace.name, 161 + isActive: rule.isActive ?? true, 162 + emailEnabled: rule.emailEnabled ?? false, 163 + ntfyEnabled: rule.ntfyEnabled ?? false, 164 + webhookEnabled: rule.webhookEnabled ?? false, 165 + projectMode: 166 + rule.projectMode === "selected" ? "selected" : ("all" as const), 167 + selectedProjectIds: rule.selectedProjects.map( 168 + (project) => project.projectId, 169 + ), 170 + createdAt: rule.createdAt, 171 + updatedAt: rule.updatedAt, 172 + })), 173 + createdAt: preference?.createdAt ?? null, 174 + updatedAt: preference?.updatedAt ?? null, 175 + }; 176 + } 177 + 178 + export async function updateNotificationPreferences( 179 + userId: string, 180 + emailAddress: string | null, 181 + input: UpdateNotificationPreferenceInput, 182 + ): Promise<NotificationPreferenceResponse> { 183 + const existing = await db.query.userNotificationPreferenceTable.findFirst({ 184 + where: eq(userNotificationPreferenceTable.userId, userId), 185 + }); 186 + 187 + const ntfyServerUrl = normalizeOptionalString( 188 + input.ntfyServerUrl ?? existing?.ntfyServerUrl, 189 + ); 190 + const ntfyTopic = normalizeOptionalString( 191 + input.ntfyTopic ?? existing?.ntfyTopic, 192 + ); 193 + const ntfyToken = normalizeOptionalString(input.ntfyToken ?? undefined); 194 + const webhookUrl = normalizeOptionalString( 195 + input.webhookUrl ?? existing?.webhookUrl, 196 + ); 197 + const webhookSecret = normalizeOptionalString( 198 + input.webhookSecret ?? undefined, 199 + ); 200 + 201 + const emailEnabled = input.emailEnabled ?? existing?.emailEnabled ?? false; 202 + const ntfyEnabled = input.ntfyEnabled ?? existing?.ntfyEnabled ?? false; 203 + const webhookEnabled = 204 + input.webhookEnabled ?? existing?.webhookEnabled ?? false; 205 + 206 + if (emailEnabled && !emailAddress) { 207 + throw new HTTPException(400, { 208 + message: "Email notifications require an account email address", 209 + }); 210 + } 211 + 212 + if (ntfyEnabled || ntfyServerUrl || ntfyTopic || ntfyToken !== undefined) { 213 + if (!ntfyServerUrl || !ntfyTopic) { 214 + throw new HTTPException(400, { 215 + message: "ntfy requires a server URL and topic", 216 + }); 217 + } 218 + 219 + try { 220 + new URL(ntfyServerUrl); 221 + await assertPublicWebhookDestination(ntfyServerUrl); 222 + } catch (error) { 223 + throw new HTTPException(400, { 224 + message: 225 + error instanceof Error ? error.message : "Invalid ntfy server URL", 226 + }); 227 + } 228 + } 229 + 230 + if (webhookEnabled || webhookUrl || webhookSecret !== undefined) { 231 + if (!webhookUrl) { 232 + throw new HTTPException(400, { 233 + message: "Webhook notifications require an endpoint URL", 234 + }); 235 + } 236 + 237 + try { 238 + new URL(webhookUrl); 239 + await assertPublicWebhookDestination(webhookUrl); 240 + } catch (error) { 241 + throw new HTTPException(400, { 242 + message: error instanceof Error ? error.message : "Invalid webhook URL", 243 + }); 244 + } 245 + } 246 + 247 + const data = { 248 + userId, 249 + emailEnabled, 250 + ntfyEnabled, 251 + ntfyServerUrl, 252 + ntfyTopic, 253 + ntfyToken: 254 + ntfyToken === undefined ? (existing?.ntfyToken ?? null) : ntfyToken, 255 + webhookEnabled, 256 + webhookUrl, 257 + webhookSecret: 258 + webhookSecret === undefined 259 + ? (existing?.webhookSecret ?? null) 260 + : webhookSecret, 261 + }; 262 + 263 + if (existing) { 264 + await db 265 + .update(userNotificationPreferenceTable) 266 + .set({ 267 + ...data, 268 + updatedAt: new Date(), 269 + }) 270 + .where(eq(userNotificationPreferenceTable.userId, userId)); 271 + } else { 272 + await db.insert(userNotificationPreferenceTable).values(data); 273 + } 274 + 275 + if (!emailEnabled) { 276 + await db 277 + .update(userNotificationWorkspaceRuleTable) 278 + .set({ emailEnabled: false, updatedAt: new Date() }) 279 + .where(eq(userNotificationWorkspaceRuleTable.userId, userId)); 280 + } 281 + 282 + if (!ntfyEnabled || !ntfyServerUrl || !ntfyTopic) { 283 + await db 284 + .update(userNotificationWorkspaceRuleTable) 285 + .set({ ntfyEnabled: false, updatedAt: new Date() }) 286 + .where(eq(userNotificationWorkspaceRuleTable.userId, userId)); 287 + } 288 + 289 + if (!webhookEnabled || !webhookUrl) { 290 + await db 291 + .update(userNotificationWorkspaceRuleTable) 292 + .set({ webhookEnabled: false, updatedAt: new Date() }) 293 + .where(eq(userNotificationWorkspaceRuleTable.userId, userId)); 294 + } 295 + 296 + return getNotificationPreferences(userId, emailAddress); 297 + } 298 + 299 + export async function upsertWorkspaceRule( 300 + userId: string, 301 + workspaceId: string, 302 + emailAddress: string | null, 303 + input: UpsertWorkspaceRuleInput, 304 + ): Promise<NotificationPreferenceResponse> { 305 + await assertWorkspaceMembership(userId, workspaceId); 306 + 307 + if (input.projectMode === "selected") { 308 + await validateProjectSelection(workspaceId, input.selectedProjectIds ?? []); 309 + } 310 + 311 + const preference = await db.query.userNotificationPreferenceTable.findFirst({ 312 + where: eq(userNotificationPreferenceTable.userId, userId), 313 + }); 314 + 315 + if (input.emailEnabled && (!preference?.emailEnabled || !emailAddress)) { 316 + throw new HTTPException(400, { 317 + message: "Enable email notifications globally before using them here", 318 + }); 319 + } 320 + 321 + if ( 322 + input.ntfyEnabled && 323 + (!preference?.ntfyEnabled || 324 + !preference.ntfyServerUrl || 325 + !preference.ntfyTopic) 326 + ) { 327 + throw new HTTPException(400, { 328 + message: "Enable ntfy notifications globally before using them here", 329 + }); 330 + } 331 + 332 + if ( 333 + input.webhookEnabled && 334 + (!preference?.webhookEnabled || !preference.webhookUrl) 335 + ) { 336 + throw new HTTPException(400, { 337 + message: "Enable webhook notifications globally before using them here", 338 + }); 339 + } 340 + 341 + const existing = await db.query.userNotificationWorkspaceRuleTable.findFirst({ 342 + where: and( 343 + eq(userNotificationWorkspaceRuleTable.userId, userId), 344 + eq(userNotificationWorkspaceRuleTable.workspaceId, workspaceId), 345 + ), 346 + }); 347 + 348 + let ruleId = existing?.id; 349 + 350 + if (existing) { 351 + await db 352 + .update(userNotificationWorkspaceRuleTable) 353 + .set({ 354 + isActive: input.isActive, 355 + emailEnabled: input.emailEnabled, 356 + ntfyEnabled: input.ntfyEnabled, 357 + webhookEnabled: input.webhookEnabled, 358 + projectMode: input.projectMode, 359 + updatedAt: new Date(), 360 + }) 361 + .where(eq(userNotificationWorkspaceRuleTable.id, existing.id)); 362 + } else { 363 + const [createdRule] = await db 364 + .insert(userNotificationWorkspaceRuleTable) 365 + .values({ 366 + userId, 367 + workspaceId, 368 + isActive: input.isActive, 369 + emailEnabled: input.emailEnabled, 370 + ntfyEnabled: input.ntfyEnabled, 371 + webhookEnabled: input.webhookEnabled, 372 + projectMode: input.projectMode, 373 + }) 374 + .returning({ id: userNotificationWorkspaceRuleTable.id }); 375 + ruleId = createdRule?.id; 376 + } 377 + 378 + if (!ruleId) { 379 + throw new HTTPException(500, { 380 + message: "Failed to save notification workspace rule", 381 + }); 382 + } 383 + 384 + const workspaceRuleId = ruleId; 385 + 386 + await db 387 + .delete(userNotificationWorkspaceProjectTable) 388 + .where( 389 + eq( 390 + userNotificationWorkspaceProjectTable.workspaceRuleId, 391 + workspaceRuleId, 392 + ), 393 + ); 394 + 395 + if (input.projectMode === "selected") { 396 + await db.insert(userNotificationWorkspaceProjectTable).values( 397 + (input.selectedProjectIds ?? []).map((projectId) => ({ 398 + workspaceRuleId, 399 + projectId, 400 + })), 401 + ); 402 + } 403 + 404 + return getNotificationPreferences(userId, emailAddress); 405 + } 406 + 407 + export async function deleteWorkspaceRule( 408 + userId: string, 409 + workspaceId: string, 410 + emailAddress: string | null, 411 + ): Promise<NotificationPreferenceResponse> { 412 + await assertWorkspaceMembership(userId, workspaceId); 413 + 414 + const existing = await db.query.userNotificationWorkspaceRuleTable.findFirst({ 415 + where: and( 416 + eq(userNotificationWorkspaceRuleTable.userId, userId), 417 + eq(userNotificationWorkspaceRuleTable.workspaceId, workspaceId), 418 + ), 419 + }); 420 + 421 + if (!existing) { 422 + throw new HTTPException(404, { 423 + message: "Workspace notification rule not found", 424 + }); 425 + } 426 + 427 + await db 428 + .delete(userNotificationWorkspaceRuleTable) 429 + .where(eq(userNotificationWorkspaceRuleTable.id, existing.id)); 430 + 431 + return getNotificationPreferences(userId, emailAddress); 432 + } 433 + 434 + export async function listWorkspaceProjects( 435 + workspaceId: string, 436 + userId: string, 437 + ) { 438 + await assertWorkspaceMembership(userId, workspaceId); 439 + 440 + return db 441 + .select({ 442 + id: projectTable.id, 443 + name: projectTable.name, 444 + workspaceId: projectTable.workspaceId, 445 + }) 446 + .from(projectTable) 447 + .where(eq(projectTable.workspaceId, workspaceId)); 448 + }
+7
apps/api/src/notification/controllers/create-notification.ts
··· 2 2 import db from "../../database"; 3 3 import { notificationTable } from "../../database/schema"; 4 4 import { publishEvent } from "../../events"; 5 + import { deliverNotification } from "../../notification-preferences/delivery"; 5 6 6 7 async function createNotification({ 7 8 userId, ··· 38 39 await publishEvent("notification.created", { 39 40 notificationId: notification.id, 40 41 userId, 42 + }); 43 + void deliverNotification(notification.id).catch((error) => { 44 + console.error("Failed to deliver notification", { 45 + notificationId: notification.id, 46 + error, 47 + }); 41 48 }); 42 49 } 43 50
+33
apps/api/src/schemas.ts
··· 98 98 createdAt: v.date(), 99 99 }); 100 100 101 + export const notificationPreferenceWorkspaceRuleSchema = v.object({ 102 + id: v.string(), 103 + workspaceId: v.string(), 104 + workspaceName: v.string(), 105 + isActive: v.boolean(), 106 + emailEnabled: v.boolean(), 107 + ntfyEnabled: v.boolean(), 108 + webhookEnabled: v.boolean(), 109 + projectMode: v.picklist(["all", "selected"] as const), 110 + selectedProjectIds: v.array(v.string()), 111 + createdAt: v.date(), 112 + updatedAt: v.date(), 113 + }); 114 + 115 + export const notificationPreferenceSchema = v.object({ 116 + emailAddress: v.nullable(v.string()), 117 + emailEnabled: v.boolean(), 118 + ntfyEnabled: v.boolean(), 119 + ntfyConfigured: v.boolean(), 120 + ntfyServerUrl: v.nullable(v.string()), 121 + ntfyTopic: v.nullable(v.string()), 122 + ntfyTokenConfigured: v.boolean(), 123 + maskedNtfyToken: v.nullable(v.string()), 124 + webhookEnabled: v.boolean(), 125 + webhookConfigured: v.boolean(), 126 + webhookUrl: v.nullable(v.string()), 127 + webhookSecretConfigured: v.boolean(), 128 + maskedWebhookSecret: v.nullable(v.string()), 129 + workspaces: v.array(notificationPreferenceWorkspaceRuleSchema), 130 + createdAt: v.nullable(v.date()), 131 + updatedAt: v.nullable(v.date()), 132 + }); 133 + 101 134 export const githubIntegrationSchema = v.object({ 102 135 id: v.string(), 103 136 projectId: v.string(),
+802
apps/web/src/components/account/notification-preferences-settings.tsx
··· 1 + import { CheckCircle, Trash2 } from "lucide-react"; 2 + import React from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + import { Button } from "@/components/ui/button"; 5 + import { Checkbox } from "@/components/ui/checkbox"; 6 + import { Input } from "@/components/ui/input"; 7 + import { Label } from "@/components/ui/label"; 8 + import { Radio, RadioGroup } from "@/components/ui/radio-group"; 9 + import { Separator } from "@/components/ui/separator"; 10 + import { Switch } from "@/components/ui/switch"; 11 + import { 12 + useDeleteNotificationWorkspaceRule, 13 + useUpdateNotificationPreferences, 14 + useUpsertNotificationWorkspaceRule, 15 + } from "@/hooks/mutations/notification-preferences/use-notification-preferences"; 16 + import useGetNotificationPreferences from "@/hooks/queries/notification-preferences/use-get-notification-preferences"; 17 + import useGetProjects from "@/hooks/queries/project/use-get-projects"; 18 + import useGetWorkspaces from "@/hooks/queries/workspace/use-get-workspaces"; 19 + import { toast } from "@/lib/toast"; 20 + 21 + type WorkspaceSummary = { 22 + id: string; 23 + name: string; 24 + }; 25 + 26 + type WorkspaceRuleState = { 27 + isActive: boolean; 28 + emailEnabled: boolean; 29 + ntfyEnabled: boolean; 30 + webhookEnabled: boolean; 31 + projectMode: "all" | "selected"; 32 + selectedProjectIds: string[]; 33 + }; 34 + 35 + function ChannelToggle({ 36 + checked, 37 + disabled, 38 + hint, 39 + label, 40 + onCheckedChange, 41 + }: { 42 + checked: boolean; 43 + disabled?: boolean; 44 + hint?: string; 45 + label: string; 46 + onCheckedChange: (checked: boolean) => void; 47 + }) { 48 + return ( 49 + <div className="flex items-center justify-between gap-4"> 50 + <div className="min-w-0 space-y-0.5"> 51 + <Label className="text-sm font-medium">{label}</Label> 52 + {hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null} 53 + </div> 54 + <Switch 55 + checked={checked} 56 + disabled={disabled} 57 + onCheckedChange={onCheckedChange} 58 + /> 59 + </div> 60 + ); 61 + } 62 + 63 + function WorkspaceRuleCard({ 64 + hasEmailChannel, 65 + hasNtfyChannel, 66 + hasWebhookChannel, 67 + onDelete, 68 + onSave, 69 + rule, 70 + workspace, 71 + }: { 72 + hasEmailChannel: boolean; 73 + hasNtfyChannel: boolean; 74 + hasWebhookChannel: boolean; 75 + onDelete: (workspaceId: string) => Promise<unknown>; 76 + onSave: (workspaceId: string, rule: WorkspaceRuleState) => Promise<void>; 77 + rule?: { 78 + isActive: boolean; 79 + emailEnabled: boolean; 80 + ntfyEnabled: boolean; 81 + webhookEnabled: boolean; 82 + projectMode: "all" | "selected"; 83 + selectedProjectIds: string[]; 84 + }; 85 + workspace: WorkspaceSummary; 86 + }) { 87 + const { t } = useTranslation(); 88 + const [state, setState] = React.useState<WorkspaceRuleState>({ 89 + isActive: rule?.isActive ?? false, 90 + emailEnabled: rule?.emailEnabled ?? false, 91 + ntfyEnabled: rule?.ntfyEnabled ?? false, 92 + webhookEnabled: rule?.webhookEnabled ?? false, 93 + projectMode: rule?.projectMode ?? "all", 94 + selectedProjectIds: rule?.selectedProjectIds ?? [], 95 + }); 96 + const [isSaving, setIsSaving] = React.useState(false); 97 + const [isDeleting, setIsDeleting] = React.useState(false); 98 + const { data: projects } = useGetProjects({ 99 + workspaceId: 100 + state.projectMode === "selected" || 101 + (rule?.projectMode ?? "all") === "selected" 102 + ? workspace.id 103 + : "", 104 + }); 105 + 106 + React.useEffect(() => { 107 + setState({ 108 + isActive: rule?.isActive ?? false, 109 + emailEnabled: rule?.emailEnabled ?? false, 110 + ntfyEnabled: rule?.ntfyEnabled ?? false, 111 + webhookEnabled: rule?.webhookEnabled ?? false, 112 + projectMode: rule?.projectMode ?? "all", 113 + selectedProjectIds: rule?.selectedProjectIds ?? [], 114 + }); 115 + }, [rule]); 116 + 117 + const isConnected = Boolean(rule); 118 + const isBusy = isSaving || isDeleting; 119 + 120 + const toggleProject = (projectId: string, checked: boolean) => { 121 + setState((current) => ({ 122 + ...current, 123 + selectedProjectIds: checked 124 + ? [...current.selectedProjectIds, projectId] 125 + : current.selectedProjectIds.filter((id) => id !== projectId), 126 + })); 127 + }; 128 + 129 + return ( 130 + <div className="space-y-4 border border-border rounded-md bg-sidebar p-4"> 131 + <div className="flex items-start justify-between gap-4"> 132 + <div className="min-w-0 space-y-1"> 133 + <p className="text-sm font-medium">{workspace.name}</p> 134 + <p className="text-xs text-muted-foreground"> 135 + {t("settings:notificationsPage.workspaceCardHint")} 136 + </p> 137 + </div> 138 + 139 + <div className="flex shrink-0 items-center gap-3"> 140 + {isConnected ? ( 141 + <div className="flex items-center gap-2 text-xs text-muted-foreground"> 142 + <CheckCircle className="size-4 text-green-600" /> 143 + <span> 144 + {state.isActive 145 + ? t("settings:notificationsPage.statusConnected") 146 + : t("settings:notificationsPage.statusPaused")} 147 + </span> 148 + </div> 149 + ) : null} 150 + <Switch 151 + checked={state.isActive} 152 + disabled={isBusy} 153 + onCheckedChange={(checked) => 154 + setState((current) => ({ ...current, isActive: checked })) 155 + } 156 + /> 157 + </div> 158 + </div> 159 + 160 + <Separator /> 161 + 162 + <div className="space-y-3"> 163 + <ChannelToggle 164 + checked={state.emailEnabled} 165 + disabled={!hasEmailChannel || isBusy} 166 + hint={ 167 + hasEmailChannel 168 + ? t("settings:notificationsPage.emailChannelHintEnabled") 169 + : t("settings:notificationsPage.emailChannelHintDisabled") 170 + } 171 + label={t("settings:notificationsPage.workspaceCardLabelEmail")} 172 + onCheckedChange={(checked) => 173 + setState((current) => ({ ...current, emailEnabled: checked })) 174 + } 175 + /> 176 + <ChannelToggle 177 + checked={state.ntfyEnabled} 178 + disabled={!hasNtfyChannel || isBusy} 179 + hint={ 180 + hasNtfyChannel 181 + ? t("settings:notificationsPage.ntfyChannelHintEnabled") 182 + : t("settings:notificationsPage.ntfyChannelHintDisabled") 183 + } 184 + label={t("settings:notificationsPage.workspaceCardLabelNtfy")} 185 + onCheckedChange={(checked) => 186 + setState((current) => ({ ...current, ntfyEnabled: checked })) 187 + } 188 + /> 189 + <ChannelToggle 190 + checked={state.webhookEnabled} 191 + disabled={!hasWebhookChannel || isBusy} 192 + hint={ 193 + hasWebhookChannel 194 + ? t("settings:notificationsPage.webhookChannelHintEnabled") 195 + : t("settings:notificationsPage.webhookChannelHintDisabled") 196 + } 197 + label={t("settings:notificationsPage.workspaceCardLabelWebhook")} 198 + onCheckedChange={(checked) => 199 + setState((current) => ({ ...current, webhookEnabled: checked })) 200 + } 201 + /> 202 + </div> 203 + 204 + <Separator /> 205 + 206 + <div className="space-y-3"> 207 + <div className="space-y-0.5"> 208 + <Label className="text-sm font-medium"> 209 + {t("settings:notificationsPage.projectScope")} 210 + </Label> 211 + <p className="text-xs text-muted-foreground"> 212 + {t("settings:notificationsPage.projectScopeDescription")} 213 + </p> 214 + </div> 215 + 216 + <RadioGroup 217 + className="gap-2" 218 + value={state.projectMode} 219 + onValueChange={(value) => 220 + setState((current) => ({ 221 + ...current, 222 + projectMode: value === "selected" ? "selected" : "all", 223 + })) 224 + } 225 + > 226 + <label 227 + className="flex items-start gap-3" 228 + htmlFor={`${workspace.id}-project-scope-all`} 229 + > 230 + <Radio 231 + className="mt-0.5" 232 + id={`${workspace.id}-project-scope-all`} 233 + value="all" 234 + /> 235 + <div className="min-w-0 space-y-0.5"> 236 + <p className="text-sm font-medium"> 237 + {t("settings:notificationsPage.allProjects")} 238 + </p> 239 + <p className="text-xs text-muted-foreground"> 240 + {t("settings:notificationsPage.allProjectsDescription")} 241 + </p> 242 + </div> 243 + </label> 244 + 245 + <label 246 + className="flex items-start gap-3" 247 + htmlFor={`${workspace.id}-project-scope-selected`} 248 + > 249 + <Radio 250 + className="mt-0.5" 251 + id={`${workspace.id}-project-scope-selected`} 252 + value="selected" 253 + /> 254 + <div className="min-w-0 space-y-0.5"> 255 + <p className="text-sm font-medium"> 256 + {t("settings:notificationsPage.selectedProjects")} 257 + </p> 258 + <p className="text-xs text-muted-foreground"> 259 + {t("settings:notificationsPage.selectedProjectsDescription")} 260 + </p> 261 + </div> 262 + </label> 263 + </RadioGroup> 264 + 265 + {state.projectMode === "selected" ? ( 266 + <div className="space-y-2 border border-dashed border-border/80 rounded-md px-3 py-3"> 267 + {!projects?.length ? ( 268 + <p className="text-sm text-muted-foreground"> 269 + {t("settings:notificationsPage.noProjectsInWorkspace")} 270 + </p> 271 + ) : ( 272 + projects.map((project) => { 273 + const checked = state.selectedProjectIds.includes(project.id); 274 + 275 + return ( 276 + <label 277 + key={project.id} 278 + className="flex items-center gap-3" 279 + htmlFor={`${workspace.id}-project-${project.id}`} 280 + > 281 + <Checkbox 282 + checked={checked} 283 + id={`${workspace.id}-project-${project.id}`} 284 + onCheckedChange={(value) => 285 + toggleProject(project.id, Boolean(value)) 286 + } 287 + /> 288 + <span className="text-sm font-medium">{project.name}</span> 289 + </label> 290 + ); 291 + }) 292 + )} 293 + </div> 294 + ) : null} 295 + </div> 296 + 297 + <Separator /> 298 + 299 + <div className="flex flex-wrap gap-2"> 300 + <Button 301 + disabled={isBusy} 302 + onClick={async () => { 303 + try { 304 + setIsSaving(true); 305 + await onSave(workspace.id, state); 306 + toast.success( 307 + t("settings:notificationsPage.toastRuleSaved", { 308 + workspaceName: workspace.name, 309 + }), 310 + ); 311 + } catch (error) { 312 + toast.error( 313 + error instanceof Error 314 + ? error.message 315 + : t("settings:notificationsPage.toastRuleSaveFailed"), 316 + ); 317 + } finally { 318 + setIsSaving(false); 319 + } 320 + }} 321 + type="button" 322 + > 323 + {isConnected 324 + ? t("settings:notificationsPage.saveChanges") 325 + : t("settings:notificationsPage.createRule")} 326 + </Button> 327 + {isConnected ? ( 328 + <Button 329 + disabled={isBusy} 330 + onClick={async () => { 331 + try { 332 + setIsDeleting(true); 333 + await onDelete(workspace.id); 334 + toast.success( 335 + t("settings:notificationsPage.toastRuleRemoved", { 336 + workspaceName: workspace.name, 337 + }), 338 + ); 339 + } catch (error) { 340 + toast.error( 341 + error instanceof Error 342 + ? error.message 343 + : t("settings:notificationsPage.toastRuleRemoveFailed"), 344 + ); 345 + } finally { 346 + setIsDeleting(false); 347 + } 348 + }} 349 + type="button" 350 + variant="outline" 351 + > 352 + <Trash2 className="size-4" /> 353 + {t("settings:notificationsPage.removeRule")} 354 + </Button> 355 + ) : null} 356 + </div> 357 + </div> 358 + ); 359 + } 360 + 361 + export function NotificationPreferencesSettings() { 362 + const { t } = useTranslation(); 363 + const { data: preferences, isLoading } = useGetNotificationPreferences(); 364 + const { data: workspacesData } = useGetWorkspaces(); 365 + const { mutateAsync: updatePreferences, isPending: isSavingPreferences } = 366 + useUpdateNotificationPreferences(); 367 + const { mutateAsync: upsertWorkspaceRule } = 368 + useUpsertNotificationWorkspaceRule(); 369 + const { mutateAsync: deleteWorkspaceRule } = 370 + useDeleteNotificationWorkspaceRule(); 371 + 372 + const workspaces = React.useMemo( 373 + () => 374 + ((workspacesData ?? []) as WorkspaceSummary[]).map((workspace) => ({ 375 + id: workspace.id, 376 + name: workspace.name, 377 + })), 378 + [workspacesData], 379 + ); 380 + 381 + const [emailEnabled, setEmailEnabled] = React.useState(false); 382 + const [ntfyEnabled, setNtfyEnabled] = React.useState(false); 383 + const [ntfyServerUrl, setNtfyServerUrl] = React.useState(""); 384 + const [ntfyTopic, setNtfyTopic] = React.useState(""); 385 + const [ntfyToken, setNtfyToken] = React.useState(""); 386 + const [webhookEnabled, setWebhookEnabled] = React.useState(false); 387 + const [webhookUrl, setWebhookUrl] = React.useState(""); 388 + const [webhookSecret, setWebhookSecret] = React.useState(""); 389 + 390 + React.useEffect(() => { 391 + if (!preferences) return; 392 + setEmailEnabled(preferences.emailEnabled); 393 + setNtfyEnabled(preferences.ntfyEnabled); 394 + setNtfyServerUrl(preferences.ntfyServerUrl ?? ""); 395 + setNtfyTopic(preferences.ntfyTopic ?? ""); 396 + setNtfyToken(""); 397 + setWebhookEnabled(preferences.webhookEnabled); 398 + setWebhookUrl(preferences.webhookUrl ?? ""); 399 + setWebhookSecret(""); 400 + }, [preferences]); 401 + 402 + const workspaceRuleMap = React.useMemo( 403 + () => 404 + new Map( 405 + (preferences?.workspaces ?? []).map((workspaceRule) => [ 406 + workspaceRule.workspaceId, 407 + workspaceRule, 408 + ]), 409 + ), 410 + [preferences?.workspaces], 411 + ); 412 + 413 + if (isLoading) { 414 + return ( 415 + <div className="space-y-4"> 416 + <div className="h-24 animate-pulse rounded-md bg-muted" /> 417 + <div className="h-24 animate-pulse rounded-md bg-muted" /> 418 + <div className="h-48 animate-pulse rounded-md bg-muted" /> 419 + </div> 420 + ); 421 + } 422 + 423 + return ( 424 + <div className="space-y-8"> 425 + <div className="space-y-4 rounded-md border border-border bg-sidebar p-4"> 426 + <div className="flex items-start justify-between gap-4"> 427 + <div className="space-y-1"> 428 + <h3 className="font-medium"> 429 + {t("settings:notificationsPage.emailTitle")} 430 + </h3> 431 + <p className="text-sm text-muted-foreground"> 432 + {t("settings:notificationsPage.emailDescription")} 433 + </p> 434 + </div> 435 + <div className="flex items-center gap-3"> 436 + {preferences?.emailEnabled ? ( 437 + <div className="flex items-center gap-2 text-sm text-muted-foreground"> 438 + <CheckCircle className="size-4 text-green-600" /> 439 + <span>{t("settings:notificationsPage.statusConnected")}</span> 440 + </div> 441 + ) : null} 442 + <Switch 443 + checked={emailEnabled} 444 + disabled={isSavingPreferences || !preferences?.emailAddress} 445 + onCheckedChange={setEmailEnabled} 446 + /> 447 + </div> 448 + </div> 449 + 450 + <div className="space-y-1"> 451 + <Label className="text-sm font-medium"> 452 + {t("settings:notificationsPage.accountEmailLabel")} 453 + </Label> 454 + <Input 455 + disabled 456 + readOnly 457 + value={ 458 + preferences?.emailAddress ?? 459 + t("settings:notificationsPage.accountEmailNoAddress") 460 + } 461 + /> 462 + <p className="text-xs text-muted-foreground"> 463 + {t("settings:notificationsPage.accountEmailHint")} 464 + </p> 465 + </div> 466 + 467 + <div className="flex gap-2"> 468 + <Button 469 + disabled={isSavingPreferences || !preferences?.emailAddress} 470 + onClick={async () => { 471 + try { 472 + await updatePreferences({ emailEnabled }); 473 + toast.success(t("settings:notificationsPage.toastEmailSaved")); 474 + } catch (error) { 475 + toast.error( 476 + error instanceof Error 477 + ? error.message 478 + : t("settings:notificationsPage.toastEmailSaveFailed"), 479 + ); 480 + } 481 + }} 482 + type="button" 483 + > 484 + {t("settings:notificationsPage.saveChanges")} 485 + </Button> 486 + </div> 487 + </div> 488 + 489 + <div className="space-y-4 rounded-md border border-border bg-sidebar p-4"> 490 + <div className="flex items-start justify-between gap-4"> 491 + <div className="space-y-1"> 492 + <h3 className="font-medium"> 493 + {t("settings:notificationsPage.ntfyTitle")} 494 + </h3> 495 + <p className="text-sm text-muted-foreground"> 496 + {t("settings:notificationsPage.ntfyDescription")} 497 + </p> 498 + </div> 499 + <div className="flex items-center gap-3"> 500 + {preferences?.ntfyConfigured ? ( 501 + <div className="flex items-center gap-2 text-sm text-muted-foreground"> 502 + <CheckCircle className="size-4 text-green-600" /> 503 + <span> 504 + {preferences.ntfyEnabled 505 + ? t("settings:notificationsPage.statusConnected") 506 + : t("settings:notificationsPage.statusPaused")} 507 + </span> 508 + </div> 509 + ) : null} 510 + <Switch 511 + checked={ntfyEnabled} 512 + disabled={isSavingPreferences} 513 + onCheckedChange={setNtfyEnabled} 514 + /> 515 + </div> 516 + </div> 517 + 518 + <div className="space-y-4"> 519 + <div className="space-y-1"> 520 + <Label className="text-sm font-medium"> 521 + {t("settings:notificationsPage.serverUrl")} 522 + </Label> 523 + <Input 524 + autoComplete="off" 525 + placeholder={t( 526 + "settings:notificationsPage.ntfyServerPlaceholder", 527 + )} 528 + value={ntfyServerUrl} 529 + onChange={(event) => setNtfyServerUrl(event.target.value)} 530 + /> 531 + </div> 532 + <div className="space-y-1"> 533 + <Label className="text-sm font-medium"> 534 + {t("settings:notificationsPage.topic")} 535 + </Label> 536 + <Input 537 + autoComplete="off" 538 + placeholder={t("settings:notificationsPage.ntfyTopicPlaceholder")} 539 + value={ntfyTopic} 540 + onChange={(event) => setNtfyTopic(event.target.value)} 541 + /> 542 + </div> 543 + <div className="space-y-1"> 544 + <Label className="text-sm font-medium"> 545 + {t("settings:notificationsPage.token")} 546 + </Label> 547 + <Input 548 + autoComplete="off" 549 + placeholder={t("settings:notificationsPage.ntfyTokenPlaceholder")} 550 + type="password" 551 + value={ntfyToken} 552 + onChange={(event) => setNtfyToken(event.target.value)} 553 + /> 554 + <p className="text-xs text-muted-foreground"> 555 + {preferences?.ntfyTokenConfigured 556 + ? t("settings:notificationsPage.ntfyTokenHintConfigured", { 557 + masked: preferences.maskedNtfyToken ?? "••••", 558 + }) 559 + : t("settings:notificationsPage.ntfyTokenHintOptional")} 560 + </p> 561 + </div> 562 + </div> 563 + 564 + <div className="flex flex-wrap gap-2"> 565 + <Button 566 + disabled={isSavingPreferences} 567 + onClick={async () => { 568 + try { 569 + await updatePreferences({ 570 + ntfyEnabled, 571 + ntfyServerUrl, 572 + ntfyTopic, 573 + ntfyToken: ntfyToken.trim() ? ntfyToken : undefined, 574 + }); 575 + setNtfyToken(""); 576 + toast.success(t("settings:notificationsPage.toastNtfySaved")); 577 + } catch (error) { 578 + toast.error( 579 + error instanceof Error 580 + ? error.message 581 + : t("settings:notificationsPage.toastNtfySaveFailed"), 582 + ); 583 + } 584 + }} 585 + type="button" 586 + > 587 + {preferences?.ntfyConfigured 588 + ? t("settings:notificationsPage.saveChanges") 589 + : t("settings:notificationsPage.connectNtfy")} 590 + </Button> 591 + {preferences?.ntfyConfigured ? ( 592 + <Button 593 + disabled={isSavingPreferences} 594 + onClick={async () => { 595 + try { 596 + await updatePreferences({ 597 + ntfyEnabled: false, 598 + ntfyServerUrl: null, 599 + ntfyTopic: null, 600 + ntfyToken: null, 601 + }); 602 + setNtfyEnabled(false); 603 + setNtfyServerUrl(""); 604 + setNtfyTopic(""); 605 + setNtfyToken(""); 606 + toast.success( 607 + t("settings:notificationsPage.toastNtfyDisconnected"), 608 + ); 609 + } catch (error) { 610 + toast.error( 611 + error instanceof Error 612 + ? error.message 613 + : t( 614 + "settings:notificationsPage.toastNtfyDisconnectFailed", 615 + ), 616 + ); 617 + } 618 + }} 619 + type="button" 620 + variant="outline" 621 + > 622 + <Trash2 className="size-4" /> 623 + {t("settings:notificationsPage.disconnect")} 624 + </Button> 625 + ) : null} 626 + </div> 627 + </div> 628 + 629 + <div className="space-y-4 rounded-md border border-border bg-sidebar p-4"> 630 + <div className="flex items-start justify-between gap-4"> 631 + <div className="space-y-1"> 632 + <h3 className="font-medium"> 633 + {t("settings:notificationsPage.webhookTitle")} 634 + </h3> 635 + <p className="text-sm text-muted-foreground"> 636 + {t("settings:notificationsPage.webhookDescription")} 637 + </p> 638 + </div> 639 + <div className="flex items-center gap-3"> 640 + {preferences?.webhookConfigured ? ( 641 + <div className="flex items-center gap-2 text-sm text-muted-foreground"> 642 + <CheckCircle className="size-4 text-green-600" /> 643 + <span> 644 + {preferences.webhookEnabled 645 + ? t("settings:notificationsPage.statusConnected") 646 + : t("settings:notificationsPage.statusPaused")} 647 + </span> 648 + </div> 649 + ) : null} 650 + <Switch 651 + checked={webhookEnabled} 652 + disabled={isSavingPreferences} 653 + onCheckedChange={setWebhookEnabled} 654 + /> 655 + </div> 656 + </div> 657 + 658 + <div className="space-y-4"> 659 + <div className="space-y-1"> 660 + <Label className="text-sm font-medium"> 661 + {t("settings:notificationsPage.endpointUrl")} 662 + </Label> 663 + <Input 664 + autoComplete="off" 665 + placeholder={t( 666 + "settings:notificationsPage.webhookUrlPlaceholder", 667 + )} 668 + type="url" 669 + value={webhookUrl} 670 + onChange={(event) => setWebhookUrl(event.target.value)} 671 + /> 672 + </div> 673 + <div className="space-y-1"> 674 + <Label className="text-sm font-medium"> 675 + {t("settings:notificationsPage.signingSecret")} 676 + </Label> 677 + <Input 678 + autoComplete="off" 679 + placeholder={t( 680 + "settings:notificationsPage.webhookSecretPlaceholder", 681 + )} 682 + type="password" 683 + value={webhookSecret} 684 + onChange={(event) => setWebhookSecret(event.target.value)} 685 + /> 686 + <p className="text-xs text-muted-foreground"> 687 + {preferences?.webhookSecretConfigured 688 + ? t("settings:notificationsPage.webhookSecretHintConfigured", { 689 + masked: preferences.maskedWebhookSecret ?? "••••", 690 + }) 691 + : t("settings:notificationsPage.webhookSecretHintOptional")} 692 + </p> 693 + </div> 694 + </div> 695 + 696 + <div className="flex flex-wrap gap-2"> 697 + <Button 698 + disabled={isSavingPreferences} 699 + onClick={async () => { 700 + try { 701 + await updatePreferences({ 702 + webhookEnabled, 703 + webhookUrl, 704 + webhookSecret: webhookSecret.trim() 705 + ? webhookSecret 706 + : undefined, 707 + }); 708 + setWebhookSecret(""); 709 + toast.success( 710 + t("settings:notificationsPage.toastWebhookSaved"), 711 + ); 712 + } catch (error) { 713 + toast.error( 714 + error instanceof Error 715 + ? error.message 716 + : t("settings:notificationsPage.toastWebhookSaveFailed"), 717 + ); 718 + } 719 + }} 720 + type="button" 721 + > 722 + {preferences?.webhookConfigured 723 + ? t("settings:notificationsPage.saveChanges") 724 + : t("settings:notificationsPage.connectWebhook")} 725 + </Button> 726 + {preferences?.webhookConfigured ? ( 727 + <Button 728 + disabled={isSavingPreferences} 729 + onClick={async () => { 730 + try { 731 + await updatePreferences({ 732 + webhookEnabled: false, 733 + webhookUrl: null, 734 + webhookSecret: null, 735 + }); 736 + setWebhookEnabled(false); 737 + setWebhookUrl(""); 738 + setWebhookSecret(""); 739 + toast.success( 740 + t("settings:notificationsPage.toastWebhookDisconnected"), 741 + ); 742 + } catch (error) { 743 + toast.error( 744 + error instanceof Error 745 + ? error.message 746 + : t( 747 + "settings:notificationsPage.toastWebhookDisconnectFailed", 748 + ), 749 + ); 750 + } 751 + }} 752 + type="button" 753 + variant="outline" 754 + > 755 + <Trash2 className="size-4" /> 756 + {t("settings:notificationsPage.disconnect")} 757 + </Button> 758 + ) : null} 759 + </div> 760 + </div> 761 + 762 + <div className="space-y-4"> 763 + <div className="space-y-1"> 764 + <h3 className="font-medium"> 765 + {t("settings:notificationsPage.workspaceRulesTitle")} 766 + </h3> 767 + <p className="text-sm text-muted-foreground"> 768 + {t("settings:notificationsPage.workspaceRulesDescription")} 769 + </p> 770 + </div> 771 + 772 + <div className="space-y-4"> 773 + {workspaces.map((workspace) => { 774 + const rule = workspaceRuleMap.get(workspace.id); 775 + 776 + return ( 777 + <WorkspaceRuleCard 778 + key={workspace.id} 779 + hasEmailChannel={Boolean(preferences?.emailEnabled)} 780 + hasNtfyChannel={Boolean( 781 + preferences?.ntfyEnabled && preferences?.ntfyConfigured, 782 + )} 783 + hasWebhookChannel={Boolean( 784 + preferences?.webhookEnabled && preferences?.webhookConfigured, 785 + )} 786 + onDelete={deleteWorkspaceRule} 787 + onSave={async (workspaceId, nextRule) => { 788 + await upsertWorkspaceRule({ 789 + workspaceId, 790 + json: nextRule, 791 + }); 792 + }} 793 + rule={rule} 794 + workspace={workspace} 795 + /> 796 + ); 797 + })} 798 + </div> 799 + </div> 800 + </div> 801 + ); 802 + }
+23
apps/web/src/fetchers/notification-preferences/delete-notification-workspace-rule.ts
··· 1 + import { getApiUrl } from "@/fetchers/get-api-url"; 2 + import type { NotificationPreferences } from "./get-notification-preferences"; 3 + 4 + async function deleteNotificationWorkspaceRule( 5 + workspaceId: string, 6 + ): Promise<NotificationPreferences> { 7 + const response = await fetch( 8 + getApiUrl(`/notification-preferences/workspaces/${workspaceId}`), 9 + { 10 + credentials: "include", 11 + method: "DELETE", 12 + }, 13 + ); 14 + 15 + if (!response.ok) { 16 + const error = await response.text(); 17 + throw new Error(error); 18 + } 19 + 20 + return (await response.json()) as NotificationPreferences; 21 + } 22 + 23 + export default deleteNotificationWorkspaceRule;
+49
apps/web/src/fetchers/notification-preferences/get-notification-preferences.ts
··· 1 + import { getApiUrl } from "@/fetchers/get-api-url"; 2 + 3 + export type NotificationPreferenceWorkspaceRule = { 4 + id: string; 5 + workspaceId: string; 6 + workspaceName: string; 7 + isActive: boolean; 8 + emailEnabled: boolean; 9 + ntfyEnabled: boolean; 10 + webhookEnabled: boolean; 11 + projectMode: "all" | "selected"; 12 + selectedProjectIds: string[]; 13 + createdAt: string; 14 + updatedAt: string; 15 + }; 16 + 17 + export type NotificationPreferences = { 18 + emailAddress: string | null; 19 + emailEnabled: boolean; 20 + ntfyEnabled: boolean; 21 + ntfyConfigured: boolean; 22 + ntfyServerUrl: string | null; 23 + ntfyTopic: string | null; 24 + ntfyTokenConfigured: boolean; 25 + maskedNtfyToken: string | null; 26 + webhookEnabled: boolean; 27 + webhookConfigured: boolean; 28 + webhookUrl: string | null; 29 + webhookSecretConfigured: boolean; 30 + maskedWebhookSecret: string | null; 31 + workspaces: NotificationPreferenceWorkspaceRule[]; 32 + createdAt: string | null; 33 + updatedAt: string | null; 34 + }; 35 + 36 + async function getNotificationPreferences(): Promise<NotificationPreferences> { 37 + const response = await fetch(getApiUrl("/notification-preferences"), { 38 + credentials: "include", 39 + }); 40 + 41 + if (!response.ok) { 42 + const error = await response.text(); 43 + throw new Error(error); 44 + } 45 + 46 + return (await response.json()) as NotificationPreferences; 47 + } 48 + 49 + export default getNotificationPreferences;
+35
apps/web/src/fetchers/notification-preferences/update-notification-preferences.ts
··· 1 + import { getApiUrl } from "@/fetchers/get-api-url"; 2 + import type { NotificationPreferences } from "./get-notification-preferences"; 3 + 4 + export type UpdateNotificationPreferencesRequest = { 5 + emailEnabled?: boolean; 6 + ntfyEnabled?: boolean; 7 + ntfyServerUrl?: string | null; 8 + ntfyTopic?: string | null; 9 + ntfyToken?: string | null; 10 + webhookEnabled?: boolean; 11 + webhookUrl?: string | null; 12 + webhookSecret?: string | null; 13 + }; 14 + 15 + async function updateNotificationPreferences( 16 + json: UpdateNotificationPreferencesRequest, 17 + ): Promise<NotificationPreferences> { 18 + const response = await fetch(getApiUrl("/notification-preferences"), { 19 + body: JSON.stringify(json), 20 + credentials: "include", 21 + headers: { 22 + "Content-Type": "application/json", 23 + }, 24 + method: "PUT", 25 + }); 26 + 27 + if (!response.ok) { 28 + const error = await response.text(); 29 + throw new Error(error); 30 + } 31 + 32 + return (await response.json()) as NotificationPreferences; 33 + } 34 + 35 + export default updateNotificationPreferences;
+37
apps/web/src/fetchers/notification-preferences/upsert-notification-workspace-rule.ts
··· 1 + import { getApiUrl } from "@/fetchers/get-api-url"; 2 + import type { NotificationPreferences } from "./get-notification-preferences"; 3 + 4 + export type UpsertNotificationWorkspaceRuleRequest = { 5 + isActive: boolean; 6 + emailEnabled: boolean; 7 + ntfyEnabled: boolean; 8 + webhookEnabled: boolean; 9 + projectMode: "all" | "selected"; 10 + selectedProjectIds?: string[]; 11 + }; 12 + 13 + async function upsertNotificationWorkspaceRule( 14 + workspaceId: string, 15 + json: UpsertNotificationWorkspaceRuleRequest, 16 + ): Promise<NotificationPreferences> { 17 + const response = await fetch( 18 + getApiUrl(`/notification-preferences/workspaces/${workspaceId}`), 19 + { 20 + body: JSON.stringify(json), 21 + credentials: "include", 22 + headers: { 23 + "Content-Type": "application/json", 24 + }, 25 + method: "PUT", 26 + }, 27 + ); 28 + 29 + if (!response.ok) { 30 + const error = await response.text(); 31 + throw new Error(error); 32 + } 33 + 34 + return (await response.json()) as NotificationPreferences; 35 + } 36 + 37 + export default upsertNotificationWorkspaceRule;
+55
apps/web/src/hooks/mutations/notification-preferences/use-notification-preferences.ts
··· 1 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 + import deleteNotificationWorkspaceRule from "@/fetchers/notification-preferences/delete-notification-workspace-rule"; 3 + import updateNotificationPreferences, { 4 + type UpdateNotificationPreferencesRequest, 5 + } from "@/fetchers/notification-preferences/update-notification-preferences"; 6 + import upsertNotificationWorkspaceRule, { 7 + type UpsertNotificationWorkspaceRuleRequest, 8 + } from "@/fetchers/notification-preferences/upsert-notification-workspace-rule"; 9 + 10 + export function useUpdateNotificationPreferences() { 11 + const queryClient = useQueryClient(); 12 + 13 + return useMutation({ 14 + mutationFn: (json: UpdateNotificationPreferencesRequest) => 15 + updateNotificationPreferences(json), 16 + onSuccess: () => { 17 + void queryClient.invalidateQueries({ 18 + queryKey: ["notification-preferences"], 19 + }); 20 + }, 21 + }); 22 + } 23 + 24 + export function useUpsertNotificationWorkspaceRule() { 25 + const queryClient = useQueryClient(); 26 + 27 + return useMutation({ 28 + mutationFn: ({ 29 + workspaceId, 30 + json, 31 + }: { 32 + workspaceId: string; 33 + json: UpsertNotificationWorkspaceRuleRequest; 34 + }) => upsertNotificationWorkspaceRule(workspaceId, json), 35 + onSuccess: () => { 36 + void queryClient.invalidateQueries({ 37 + queryKey: ["notification-preferences"], 38 + }); 39 + }, 40 + }); 41 + } 42 + 43 + export function useDeleteNotificationWorkspaceRule() { 44 + const queryClient = useQueryClient(); 45 + 46 + return useMutation({ 47 + mutationFn: (workspaceId: string) => 48 + deleteNotificationWorkspaceRule(workspaceId), 49 + onSuccess: () => { 50 + void queryClient.invalidateQueries({ 51 + queryKey: ["notification-preferences"], 52 + }); 53 + }, 54 + }); 55 + }
+11
apps/web/src/hooks/queries/notification-preferences/use-get-notification-preferences.ts
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import getNotificationPreferences from "@/fetchers/notification-preferences/get-notification-preferences"; 3 + 4 + function useGetNotificationPreferences() { 5 + return useQuery({ 6 + queryFn: getNotificationPreferences, 7 + queryKey: ["notification-preferences"], 8 + }); 9 + } 10 + 11 + export default useGetNotificationPreferences;
+23
apps/web/src/routeTree.gen.ts
··· 37 37 import { Route as LayoutAuthenticatedDashboardWorkspaceWorkspaceIdMembersRouteImport } from './routes/_layout/_authenticated/dashboard/workspace/$workspaceId/members' 38 38 import { Route as LayoutAuthenticatedDashboardSettingsWorkspaceGeneralRouteImport } from './routes/_layout/_authenticated/dashboard/settings/workspace/general' 39 39 import { Route as LayoutAuthenticatedDashboardSettingsAccountPreferencesRouteImport } from './routes/_layout/_authenticated/dashboard/settings/account/preferences' 40 + import { Route as LayoutAuthenticatedDashboardSettingsAccountNotificationsRouteImport } from './routes/_layout/_authenticated/dashboard/settings/account/notifications' 40 41 import { Route as LayoutAuthenticatedDashboardSettingsAccountInformationRouteImport } from './routes/_layout/_authenticated/dashboard/settings/account/information' 41 42 import { Route as LayoutAuthenticatedDashboardSettingsAccountDeveloperRouteImport } from './routes/_layout/_authenticated/dashboard/settings/account/developer' 42 43 import { Route as LayoutAuthenticatedDashboardSettingsProjectsProjectIdWorkflowRouteImport } from './routes/_layout/_authenticated/dashboard/settings/projects/$projectId/workflow' ··· 205 206 path: '/preferences', 206 207 getParentRoute: () => LayoutAuthenticatedDashboardSettingsAccountRoute, 207 208 } as any) 209 + const LayoutAuthenticatedDashboardSettingsAccountNotificationsRoute = 210 + LayoutAuthenticatedDashboardSettingsAccountNotificationsRouteImport.update({ 211 + id: '/notifications', 212 + path: '/notifications', 213 + getParentRoute: () => LayoutAuthenticatedDashboardSettingsAccountRoute, 214 + } as any) 208 215 const LayoutAuthenticatedDashboardSettingsAccountInformationRoute = 209 216 LayoutAuthenticatedDashboardSettingsAccountInformationRouteImport.update({ 210 217 id: '/information', ··· 319 326 '/dashboard/workspace/create': typeof LayoutAuthenticatedDashboardWorkspaceCreateRoute 320 327 '/dashboard/settings/account/developer': typeof LayoutAuthenticatedDashboardSettingsAccountDeveloperRoute 321 328 '/dashboard/settings/account/information': typeof LayoutAuthenticatedDashboardSettingsAccountInformationRoute 329 + '/dashboard/settings/account/notifications': typeof LayoutAuthenticatedDashboardSettingsAccountNotificationsRoute 322 330 '/dashboard/settings/account/preferences': typeof LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute 323 331 '/dashboard/settings/workspace/general': typeof LayoutAuthenticatedDashboardSettingsWorkspaceGeneralRoute 324 332 '/dashboard/workspace/$workspaceId/members': typeof LayoutAuthenticatedDashboardWorkspaceWorkspaceIdMembersRoute ··· 356 364 '/dashboard/workspace/create': typeof LayoutAuthenticatedDashboardWorkspaceCreateRoute 357 365 '/dashboard/settings/account/developer': typeof LayoutAuthenticatedDashboardSettingsAccountDeveloperRoute 358 366 '/dashboard/settings/account/information': typeof LayoutAuthenticatedDashboardSettingsAccountInformationRoute 367 + '/dashboard/settings/account/notifications': typeof LayoutAuthenticatedDashboardSettingsAccountNotificationsRoute 359 368 '/dashboard/settings/account/preferences': typeof LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute 360 369 '/dashboard/settings/workspace/general': typeof LayoutAuthenticatedDashboardSettingsWorkspaceGeneralRoute 361 370 '/dashboard/workspace/$workspaceId/members': typeof LayoutAuthenticatedDashboardWorkspaceWorkspaceIdMembersRoute ··· 398 407 '/_layout/_authenticated/dashboard/workspace/create': typeof LayoutAuthenticatedDashboardWorkspaceCreateRoute 399 408 '/_layout/_authenticated/dashboard/settings/account/developer': typeof LayoutAuthenticatedDashboardSettingsAccountDeveloperRoute 400 409 '/_layout/_authenticated/dashboard/settings/account/information': typeof LayoutAuthenticatedDashboardSettingsAccountInformationRoute 410 + '/_layout/_authenticated/dashboard/settings/account/notifications': typeof LayoutAuthenticatedDashboardSettingsAccountNotificationsRoute 401 411 '/_layout/_authenticated/dashboard/settings/account/preferences': typeof LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute 402 412 '/_layout/_authenticated/dashboard/settings/workspace/general': typeof LayoutAuthenticatedDashboardSettingsWorkspaceGeneralRoute 403 413 '/_layout/_authenticated/dashboard/workspace/$workspaceId/members': typeof LayoutAuthenticatedDashboardWorkspaceWorkspaceIdMembersRoute ··· 439 449 | '/dashboard/workspace/create' 440 450 | '/dashboard/settings/account/developer' 441 451 | '/dashboard/settings/account/information' 452 + | '/dashboard/settings/account/notifications' 442 453 | '/dashboard/settings/account/preferences' 443 454 | '/dashboard/settings/workspace/general' 444 455 | '/dashboard/workspace/$workspaceId/members' ··· 476 487 | '/dashboard/workspace/create' 477 488 | '/dashboard/settings/account/developer' 478 489 | '/dashboard/settings/account/information' 490 + | '/dashboard/settings/account/notifications' 479 491 | '/dashboard/settings/account/preferences' 480 492 | '/dashboard/settings/workspace/general' 481 493 | '/dashboard/workspace/$workspaceId/members' ··· 517 529 | '/_layout/_authenticated/dashboard/workspace/create' 518 530 | '/_layout/_authenticated/dashboard/settings/account/developer' 519 531 | '/_layout/_authenticated/dashboard/settings/account/information' 532 + | '/_layout/_authenticated/dashboard/settings/account/notifications' 520 533 | '/_layout/_authenticated/dashboard/settings/account/preferences' 521 534 | '/_layout/_authenticated/dashboard/settings/workspace/general' 522 535 | '/_layout/_authenticated/dashboard/workspace/$workspaceId/members' ··· 740 753 preLoaderRoute: typeof LayoutAuthenticatedDashboardSettingsAccountPreferencesRouteImport 741 754 parentRoute: typeof LayoutAuthenticatedDashboardSettingsAccountRoute 742 755 } 756 + '/_layout/_authenticated/dashboard/settings/account/notifications': { 757 + id: '/_layout/_authenticated/dashboard/settings/account/notifications' 758 + path: '/notifications' 759 + fullPath: '/dashboard/settings/account/notifications' 760 + preLoaderRoute: typeof LayoutAuthenticatedDashboardSettingsAccountNotificationsRouteImport 761 + parentRoute: typeof LayoutAuthenticatedDashboardSettingsAccountRoute 762 + } 743 763 '/_layout/_authenticated/dashboard/settings/account/information': { 744 764 id: '/_layout/_authenticated/dashboard/settings/account/information' 745 765 path: '/information' ··· 823 843 interface LayoutAuthenticatedDashboardSettingsAccountRouteChildren { 824 844 LayoutAuthenticatedDashboardSettingsAccountDeveloperRoute: typeof LayoutAuthenticatedDashboardSettingsAccountDeveloperRoute 825 845 LayoutAuthenticatedDashboardSettingsAccountInformationRoute: typeof LayoutAuthenticatedDashboardSettingsAccountInformationRoute 846 + LayoutAuthenticatedDashboardSettingsAccountNotificationsRoute: typeof LayoutAuthenticatedDashboardSettingsAccountNotificationsRoute 826 847 LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute: typeof LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute 827 848 } 828 849 ··· 832 853 LayoutAuthenticatedDashboardSettingsAccountDeveloperRoute, 833 854 LayoutAuthenticatedDashboardSettingsAccountInformationRoute: 834 855 LayoutAuthenticatedDashboardSettingsAccountInformationRoute, 856 + LayoutAuthenticatedDashboardSettingsAccountNotificationsRoute: 857 + LayoutAuthenticatedDashboardSettingsAccountNotificationsRoute, 835 858 LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute: 836 859 LayoutAuthenticatedDashboardSettingsAccountPreferencesRoute, 837 860 }
+6 -1
apps/web/src/routes/_layout/_authenticated/dashboard/settings/account.tsx
··· 4 4 Outlet, 5 5 useLocation, 6 6 } from "@tanstack/react-router"; 7 - import { Code, Settings, User } from "lucide-react"; 7 + import { Bell, Code, Settings, User } from "lucide-react"; 8 8 import { useTranslation } from "react-i18next"; 9 9 import useAuth from "@/components/providers/auth-provider/hooks/use-auth"; 10 10 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; ··· 34 34 title: t("settings:information"), 35 35 url: "/dashboard/settings/account/information", 36 36 icon: User, 37 + }, 38 + { 39 + title: t("settings:notifications"), 40 + url: "/dashboard/settings/account/notifications", 41 + icon: Bell, 37 42 }, 38 43 { 39 44 title: t("settings:preferences"),
+32
apps/web/src/routes/_layout/_authenticated/dashboard/settings/account/notifications.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { useTranslation } from "react-i18next"; 3 + import { NotificationPreferencesSettings } from "@/components/account/notification-preferences-settings"; 4 + import PageTitle from "@/components/page-title"; 5 + 6 + export const Route = createFileRoute( 7 + "/_layout/_authenticated/dashboard/settings/account/notifications", 8 + )({ 9 + component: RouteComponent, 10 + }); 11 + 12 + function RouteComponent() { 13 + const { t } = useTranslation(); 14 + 15 + return ( 16 + <> 17 + <PageTitle title={t("settings:notificationsPage.pageTitle")} /> 18 + <div className="max-w-4xl mx-auto space-y-8"> 19 + <div className="space-y-2"> 20 + <h1 className="text-2xl font-semibold"> 21 + {t("settings:notificationsPage.title")} 22 + </h1> 23 + <p className="text-muted-foreground"> 24 + {t("settings:notificationsPage.subtitle")} 25 + </p> 26 + </div> 27 + 28 + <NotificationPreferencesSettings /> 29 + </div> 30 + </> 31 + ); 32 + }
+70
i18n/de-DE.json
··· 298 298 "account": "Konto", 299 299 "developer": "Entwicklung", 300 300 "information": "Informationen", 301 + "notifications": "Benachrichtigungen", 301 302 "preferences": "Einstellungen", 302 303 "apiKeys": "API-Schlüssel", 303 304 "informationPage": { ··· 318 319 "nameShort": "Der Name muss mindestens 2 Zeichen lang sein", 319 320 "invalidEmail": "Ungültige E-Mail-Adresse" 320 321 } 322 + }, 323 + "notificationsPage": { 324 + "pageTitle": "Benachrichtigungen", 325 + "title": "Benachrichtigungen", 326 + "subtitle": "Lege fest, wie Kaneo deine Kontobenachrichtigungen zustellt und welche Kanäle du nutzt.", 327 + "statusConnected": "Verbunden", 328 + "statusPaused": "Pausiert", 329 + "emailTitle": "E-Mail", 330 + "emailDescription": "Nutze deine Konten-E-Mail als Ziel für Benachrichtigungen.", 331 + "accountEmailLabel": "Konten-E-Mail", 332 + "accountEmailNoAddress": "Keine Konten-E-Mail vorhanden", 333 + "accountEmailHint": "E-Mail-Zustellung erfolgt immer an die E-Mail des angemeldeten Kontos.", 334 + "saveChanges": "Änderungen speichern", 335 + "disconnect": "Trennen", 336 + "ntfyTitle": "ntfy", 337 + "ntfyDescription": "Kontobenachrichtigungen an ein ntfy-Thema senden.", 338 + "serverUrl": "Server-URL", 339 + "topic": "Thema", 340 + "token": "Token", 341 + "ntfyServerPlaceholder": "https://ntfy.example.com", 342 + "ntfyTopicPlaceholder": "team-alerts", 343 + "ntfyTokenPlaceholder": "Optionaler Bearer-Token", 344 + "ntfyTokenHintConfigured": "Ein Token ist bereits konfiguriert ({{masked}}). Gib einen neuen Token ein, um ihn zu ersetzen.", 345 + "ntfyTokenHintOptional": "Optional. Gib einen Token an, wenn dein ntfy-Server eine Authentifizierung verlangt.", 346 + "connectNtfy": "ntfy verbinden", 347 + "webhookTitle": "Benutzerdefinierter Webhook", 348 + "webhookDescription": "Kontobenachrichtigungen als JSON an deinen eigenen Endpunkt senden.", 349 + "endpointUrl": "Endpunkt-URL", 350 + "signingSecret": "Signatur-Geheimnis", 351 + "webhookUrlPlaceholder": "https://example.com/webhooks/kaneo", 352 + "webhookSecretPlaceholder": "Optionales gemeinsames Geheimnis", 353 + "webhookSecretHintConfigured": "Ein Signatur-Geheimnis ist bereits konfiguriert ({{masked}}). Gib ein neues ein, um es zu ersetzen.", 354 + "webhookSecretHintOptional": "Optional. Kaneo signiert den Anfragetext, wenn ein Geheimnis gesetzt ist.", 355 + "connectWebhook": "Webhook verbinden", 356 + "workspaceRulesTitle": "Zustellregeln für Arbeitsbereiche", 357 + "workspaceRulesDescription": "Nutze deine globalen Kanäle erneut und lege fest, welche Arbeitsbereiche und Projekte Kontobenachrichtigungen senden dürfen.", 358 + "workspaceCardHint": "Wähle, welche Kanäle dieser Arbeitsbereich für Kontobenachrichtigungen nutzen darf.", 359 + "workspaceCardLabelEmail": "E-Mail", 360 + "workspaceCardLabelNtfy": "ntfy", 361 + "workspaceCardLabelWebhook": "Benutzerdefinierter Webhook", 362 + "emailChannelHintEnabled": "Passende Arbeitsbereichs-Benachrichtigungen per E-Mail senden.", 363 + "emailChannelHintDisabled": "E-Mail zuerst global konfigurieren und aktivieren.", 364 + "ntfyChannelHintEnabled": "Passende Arbeitsbereichs-Benachrichtigungen an ntfy senden.", 365 + "ntfyChannelHintDisabled": "ntfy zuerst global konfigurieren und aktivieren.", 366 + "webhookChannelHintEnabled": "Passende Arbeitsbereichs-Benachrichtigungen an deinen Webhook senden.", 367 + "webhookChannelHintDisabled": "Webhook zuerst global konfigurieren und aktivieren.", 368 + "projectScope": "Projektbereich", 369 + "projectScopeDescription": "Standardmäßig sind alle Projekte enthalten. Schränke ein, wenn dieser Arbeitsbereich nur ausgewählte Projekte benachrichtigen soll.", 370 + "allProjects": "Alle Projekte", 371 + "allProjectsDescription": "Benachrichtigungen aus jedem Projekt in diesem Arbeitsbereich zustellen.", 372 + "selectedProjects": "Ausgewählte Projekte", 373 + "selectedProjectsDescription": "Nur Benachrichtigungen aus gewählten Projekten zustellen.", 374 + "noProjectsInWorkspace": "Für diesen Arbeitsbereich sind noch keine Projekte vorhanden.", 375 + "createRule": "Regel erstellen", 376 + "removeRule": "Regel entfernen", 377 + "toastEmailSaved": "E-Mail-Benachrichtigungseinstellungen gespeichert", 378 + "toastEmailSaveFailed": "E-Mail-Einstellungen konnten nicht gespeichert werden", 379 + "toastNtfySaved": "ntfy-Einstellungen gespeichert", 380 + "toastNtfySaveFailed": "ntfy-Einstellungen konnten nicht gespeichert werden", 381 + "toastNtfyDisconnected": "ntfy getrennt", 382 + "toastNtfyDisconnectFailed": "ntfy konnte nicht getrennt werden", 383 + "toastWebhookSaved": "Webhook-Einstellungen gespeichert", 384 + "toastWebhookSaveFailed": "Webhook-Einstellungen konnten nicht gespeichert werden", 385 + "toastWebhookDisconnected": "Webhook getrennt", 386 + "toastWebhookDisconnectFailed": "Webhook konnte nicht getrennt werden", 387 + "toastRuleSaved": "Benachrichtigungsregel für {{workspaceName}} gespeichert", 388 + "toastRuleSaveFailed": "Arbeitsbereichs-Benachrichtigungsregel konnte nicht gespeichert werden", 389 + "toastRuleRemoved": "Benachrichtigungsregel für {{workspaceName}} entfernt", 390 + "toastRuleRemoveFailed": "Arbeitsbereichs-Benachrichtigungsregel konnte nicht entfernt werden" 321 391 }, 322 392 "preferencesPage": { 323 393 "title": "Einstellungen",
+70
i18n/el-GR.json
··· 298 298 "account": "Λογαριασμός", 299 299 "developer": "Προγραμματιστής", 300 300 "information": "Πληροφορίες", 301 + "notifications": "Ειδοποιήσεις", 301 302 "preferences": "Προτιμήσεις", 302 303 "apiKeys": "Κλειδιά API", 303 304 "informationPage": { ··· 318 319 "nameShort": "Το όνομα πρέπει να έχει τουλάχιστον 2 χαρακτήρες", 319 320 "invalidEmail": "Μη έγκυρη διεύθυνση email" 320 321 } 322 + }, 323 + "notificationsPage": { 324 + "pageTitle": "Ειδοποιήσεις", 325 + "title": "Ειδοποιήσεις", 326 + "subtitle": "Επιλέξτε πώς το Kaneo παραδίδει τις ειδοποιήσεις λογαριασμού και ποια κανάλια θα χρησιμοποιούνται.", 327 + "statusConnected": "Συνδεδεμένο", 328 + "statusPaused": "Σε παύση", 329 + "emailTitle": "Email", 330 + "emailDescription": "Χρησιμοποιήστε το email του λογαριασμού ως προορισμό για ειδοποιήσεις.", 331 + "accountEmailLabel": "Email λογαριασμού", 332 + "accountEmailNoAddress": "Δεν υπάρχει διαθέσιμο email λογαριασμού", 333 + "accountEmailHint": "Η παράδοση email πηγαίνει πάντα στο email του συνδεδεμένου λογαριασμού.", 334 + "saveChanges": "Αποθήκευση αλλαγών", 335 + "disconnect": "Αποσύνδεση", 336 + "ntfyTitle": "ntfy", 337 + "ntfyDescription": "Δημοσιεύστε ειδοποιήσεις λογαριασμού σε θέμα ntfy.", 338 + "serverUrl": "URL διακομιστή", 339 + "topic": "Θέμα", 340 + "token": "Διακριτικό", 341 + "ntfyServerPlaceholder": "https://ntfy.example.com", 342 + "ntfyTopicPlaceholder": "team-alerts", 343 + "ntfyTokenPlaceholder": "Προαιρετικό bearer token", 344 + "ntfyTokenHintConfigured": "Ένα διακριτικό έχει ήδη ρυθμιστεί ({{masked}}). Εισαγάγετε νέο για αντικατάσταση.", 345 + "ntfyTokenHintOptional": "Προαιρετικό. Δώστε διακριτικό αν ο διακομιστής ntfy απαιτεί πιστοποίηση.", 346 + "connectNtfy": "Σύνδεση ntfy", 347 + "webhookTitle": "Προσαρμοσμένο webhook", 348 + "webhookDescription": "Στείλτε ειδοποιήσεις λογαριασμού στο δικό σας endpoint ως JSON.", 349 + "endpointUrl": "URL τελικού σημείου", 350 + "signingSecret": "Μυστικό υπογραφής", 351 + "webhookUrlPlaceholder": "https://example.com/webhooks/kaneo", 352 + "webhookSecretPlaceholder": "Προαιρετικό κοινό μυστικό", 353 + "webhookSecretHintConfigured": "Ένα μυστικό υπογραφής έχει ήδη ρυθμιστεί ({{masked}}). Εισαγάγετε νέο για αντικατάσταση.", 354 + "webhookSecretHintOptional": "Προαιρετικό. Το Kaneo υπογράφει το σώμα αιτήματος όταν έχει οριστεί μυστικό.", 355 + "connectWebhook": "Σύνδεση webhook", 356 + "workspaceRulesTitle": "Κανόνες παράδοσης χώρου εργασίας", 357 + "workspaceRulesDescription": "Επαναχρησιμοποιήστε τα καθολικά κανάλια και επιλέξτε ποιοι χώροι εργασίας και έργα επιτρέπεται να στέλνουν ειδοποιήσεις λογαριασμού.", 358 + "workspaceCardHint": "Επιλέξτε ποια κανάλια μπορεί να χρησιμοποιεί αυτός ο χώρος εργασίας για ειδοποιήσεις λογαριασμού.", 359 + "workspaceCardLabelEmail": "Email", 360 + "workspaceCardLabelNtfy": "ntfy", 361 + "workspaceCardLabelWebhook": "Προσαρμοσμένο webhook", 362 + "emailChannelHintEnabled": "Αποστολή αντίστοιχων ειδοποιήσεων χώρου εργασίας μέσω email.", 363 + "emailChannelHintDisabled": "Ρυθμίστε και ενεργοποιήστε πρώτα το email καθολικά.", 364 + "ntfyChannelHintEnabled": "Αποστολή αντίστοιχων ειδοποιήσεων χώρου εργασίας στο ntfy.", 365 + "ntfyChannelHintDisabled": "Ρυθμίστε και ενεργοποιήστε πρώτα το ntfy καθολικά.", 366 + "webhookChannelHintEnabled": "Αποστολή αντίστοιχων ειδοποιήσεων χώρου εργασίας στο webhook σας.", 367 + "webhookChannelHintDisabled": "Ρυθμίστε και ενεργοποιήστε πρώτα το webhook καθολικά.", 368 + "projectScope": "Εύρος έργων", 369 + "projectScopeDescription": "Όλα τα έργα περιλαμβάνονται από προεπιλογή. Περιορίστε αν αυτός ο χώρος εργασίας πρέπει να στέλνει ειδοποιήσεις μόνο από επιλεγμένα έργα.", 370 + "allProjects": "Όλα τα έργα", 371 + "allProjectsDescription": "Παράδοση ειδοποιήσεων από κάθε έργο σε αυτόν τον χώρο εργασίας.", 372 + "selectedProjects": "Επιλεγμένα έργα", 373 + "selectedProjectsDescription": "Παράδοση ειδοποιήσεων μόνο από επιλεγμένα έργα.", 374 + "noProjectsInWorkspace": "Δεν υπάρχουν ακόμα διαθέσιμα έργα για αυτόν τον χώρο εργασίας.", 375 + "createRule": "Δημιουργία κανόνα", 376 + "removeRule": "Αφαίρεση κανόνα", 377 + "toastEmailSaved": "Οι ρυθμίσεις ειδοποιήσεων email αποθηκεύτηκαν", 378 + "toastEmailSaveFailed": "Αποτυχία αποθήκευσης ρυθμίσεων email", 379 + "toastNtfySaved": "Οι ρυθμίσεις ntfy αποθηκεύτηκαν", 380 + "toastNtfySaveFailed": "Αποτυχία αποθήκευσης ρυθμίσεων ntfy", 381 + "toastNtfyDisconnected": "Το ntfy αποσυνδέθηκε", 382 + "toastNtfyDisconnectFailed": "Αποτυχία αποσύνδεσης ntfy", 383 + "toastWebhookSaved": "Οι ρυθμίσεις webhook αποθηκεύτηκαν", 384 + "toastWebhookSaveFailed": "Αποτυχία αποθήκευσης ρυθμίσεων webhook", 385 + "toastWebhookDisconnected": "Το webhook αποσυνδέθηκε", 386 + "toastWebhookDisconnectFailed": "Αποτυχία αποσύνδεσης webhook", 387 + "toastRuleSaved": "Αποθηκεύτηκε ο κανόνας ειδοποίησης για {{workspaceName}}", 388 + "toastRuleSaveFailed": "Αποτυχία αποθήκευσης κανόνα ειδοποίησης χώρου εργασίας", 389 + "toastRuleRemoved": "Αφαιρέθηκε ο κανόνας ειδοποίησης για {{workspaceName}}", 390 + "toastRuleRemoveFailed": "Αποτυχία αφαίρεσης κανόνα ειδοποίησης χώρου εργασίας" 321 391 }, 322 392 "preferencesPage": { 323 393 "title": "Προτιμήσεις",
+70
i18n/en-US.json
··· 298 298 "account": "Account", 299 299 "developer": "Developer", 300 300 "information": "Information", 301 + "notifications": "Notifications", 301 302 "preferences": "Preferences", 302 303 "apiKeys": "API Keys", 303 304 "informationPage": { ··· 318 319 "nameShort": "Name must be at least 2 characters", 319 320 "invalidEmail": "Invalid email address" 320 321 } 322 + }, 323 + "notificationsPage": { 324 + "pageTitle": "Notifications", 325 + "title": "Notifications", 326 + "subtitle": "Choose how Kaneo delivers your account notifications and which channels to use.", 327 + "statusConnected": "Connected", 328 + "statusPaused": "Paused", 329 + "emailTitle": "Email", 330 + "emailDescription": "Use your account email as the destination for notifications.", 331 + "accountEmailLabel": "Account email", 332 + "accountEmailNoAddress": "No account email available", 333 + "accountEmailHint": "Email delivery always goes to the signed-in account email.", 334 + "saveChanges": "Save changes", 335 + "disconnect": "Disconnect", 336 + "ntfyTitle": "ntfy", 337 + "ntfyDescription": "Publish account notifications to an ntfy topic.", 338 + "serverUrl": "Server URL", 339 + "topic": "Topic", 340 + "token": "Token", 341 + "ntfyServerPlaceholder": "https://ntfy.example.com", 342 + "ntfyTopicPlaceholder": "team-alerts", 343 + "ntfyTokenPlaceholder": "Optional bearer token", 344 + "ntfyTokenHintConfigured": "A token is already configured ({{masked}}). Enter a new token to replace it.", 345 + "ntfyTokenHintOptional": "Optional. Provide a token if your ntfy server requires authentication.", 346 + "connectNtfy": "Connect ntfy", 347 + "webhookTitle": "Custom webhook", 348 + "webhookDescription": "Send account notifications to your own endpoint as JSON.", 349 + "endpointUrl": "Endpoint URL", 350 + "signingSecret": "Signing secret", 351 + "webhookUrlPlaceholder": "https://example.com/webhooks/kaneo", 352 + "webhookSecretPlaceholder": "Optional shared secret", 353 + "webhookSecretHintConfigured": "A signing secret is already configured ({{masked}}). Enter a new one to replace it.", 354 + "webhookSecretHintOptional": "Optional. Kaneo signs the request body when a secret is set.", 355 + "connectWebhook": "Connect webhook", 356 + "workspaceRulesTitle": "Workspace delivery rules", 357 + "workspaceRulesDescription": "Reuse your global channels, then decide which workspaces and projects are allowed to send account notifications.", 358 + "workspaceCardHint": "Choose which channels this workspace can use for account notifications.", 359 + "workspaceCardLabelEmail": "Email", 360 + "workspaceCardLabelNtfy": "ntfy", 361 + "workspaceCardLabelWebhook": "Custom webhook", 362 + "emailChannelHintEnabled": "Send matching workspace notifications by email.", 363 + "emailChannelHintDisabled": "Configure and enable email globally first.", 364 + "ntfyChannelHintEnabled": "Send matching workspace notifications to ntfy.", 365 + "ntfyChannelHintDisabled": "Configure and enable ntfy globally first.", 366 + "webhookChannelHintEnabled": "Send matching workspace notifications to your webhook.", 367 + "webhookChannelHintDisabled": "Configure and enable the webhook globally first.", 368 + "projectScope": "Project scope", 369 + "projectScopeDescription": "All projects are included by default. Narrow it down if this workspace should only send notifications from selected projects.", 370 + "allProjects": "All projects", 371 + "allProjectsDescription": "Deliver notifications from every project in this workspace.", 372 + "selectedProjects": "Selected projects", 373 + "selectedProjectsDescription": "Only deliver notifications from chosen projects.", 374 + "noProjectsInWorkspace": "No projects available for this workspace yet.", 375 + "createRule": "Create rule", 376 + "removeRule": "Remove rule", 377 + "toastEmailSaved": "Email notification settings saved", 378 + "toastEmailSaveFailed": "Failed to save email settings", 379 + "toastNtfySaved": "ntfy settings saved", 380 + "toastNtfySaveFailed": "Failed to save ntfy settings", 381 + "toastNtfyDisconnected": "ntfy disconnected", 382 + "toastNtfyDisconnectFailed": "Failed to disconnect ntfy", 383 + "toastWebhookSaved": "Webhook settings saved", 384 + "toastWebhookSaveFailed": "Failed to save webhook settings", 385 + "toastWebhookDisconnected": "Webhook disconnected", 386 + "toastWebhookDisconnectFailed": "Failed to disconnect webhook", 387 + "toastRuleSaved": "Saved notification rule for {{workspaceName}}", 388 + "toastRuleSaveFailed": "Failed to save workspace notification rule", 389 + "toastRuleRemoved": "Removed notification rule for {{workspaceName}}", 390 + "toastRuleRemoveFailed": "Failed to remove workspace notification rule" 321 391 }, 322 392 "preferencesPage": { 323 393 "title": "Preferences",
+70
i18n/fr-FR.json
··· 298 298 "account": "Compte", 299 299 "developer": "Développeur", 300 300 "information": "Information", 301 + "notifications": "Notifications", 301 302 "preferences": "Préférences", 302 303 "apiKeys": "Clés API", 303 304 "informationPage": { ··· 318 319 "nameShort": "Le nom doit contenir au moins 2 caractères", 319 320 "invalidEmail": "Adresse email invalide" 320 321 } 322 + }, 323 + "notificationsPage": { 324 + "pageTitle": "Notifications", 325 + "title": "Notifications", 326 + "subtitle": "Choisissez comment Kaneo envoie les notifications de compte et quels canaux utiliser.", 327 + "statusConnected": "Connecté", 328 + "statusPaused": "En pause", 329 + "emailTitle": "E-mail", 330 + "emailDescription": "Utilisez l’e-mail du compte comme destination des notifications.", 331 + "accountEmailLabel": "E-mail du compte", 332 + "accountEmailNoAddress": "Aucun e-mail de compte disponible", 333 + "accountEmailHint": "La livraison par e-mail va toujours à l’e-mail du compte connecté.", 334 + "saveChanges": "Enregistrer les modifications", 335 + "disconnect": "Déconnecter", 336 + "ntfyTitle": "ntfy", 337 + "ntfyDescription": "Publier les notifications de compte sur un sujet ntfy.", 338 + "serverUrl": "URL du serveur", 339 + "topic": "Sujet", 340 + "token": "Jeton", 341 + "ntfyServerPlaceholder": "https://ntfy.example.com", 342 + "ntfyTopicPlaceholder": "team-alerts", 343 + "ntfyTokenPlaceholder": "Jeton Bearer facultatif", 344 + "ntfyTokenHintConfigured": "Un jeton est déjà configuré ({{masked}}). Saisissez-en un nouveau pour le remplacer.", 345 + "ntfyTokenHintOptional": "Facultatif. Indiquez un jeton si votre serveur ntfy exige une authentification.", 346 + "connectNtfy": "Connecter ntfy", 347 + "webhookTitle": "Webhook personnalisé", 348 + "webhookDescription": "Envoyer les notifications de compte vers votre propre point de terminaison en JSON.", 349 + "endpointUrl": "URL du point de terminaison", 350 + "signingSecret": "Secret de signature", 351 + "webhookUrlPlaceholder": "https://example.com/webhooks/kaneo", 352 + "webhookSecretPlaceholder": "Secret partagé facultatif", 353 + "webhookSecretHintConfigured": "Un secret de signature est déjà configuré ({{masked}}). Saisissez-en un nouveau pour le remplacer.", 354 + "webhookSecretHintOptional": "Facultatif. Kaneo signe le corps de la requête lorsqu’un secret est défini.", 355 + "connectWebhook": "Connecter le webhook", 356 + "workspaceRulesTitle": "Règles de livraison par espace de travail", 357 + "workspaceRulesDescription": "Réutilisez vos canaux globaux, puis choisissez quels espaces de travail et projets peuvent envoyer des notifications de compte.", 358 + "workspaceCardHint": "Choisissez les canaux que cet espace de travail peut utiliser pour les notifications de compte.", 359 + "workspaceCardLabelEmail": "E-mail", 360 + "workspaceCardLabelNtfy": "ntfy", 361 + "workspaceCardLabelWebhook": "Webhook personnalisé", 362 + "emailChannelHintEnabled": "Envoyer les notifications d’espace de travail correspondantes par e-mail.", 363 + "emailChannelHintDisabled": "Configurez et activez d’abord l’e-mail globalement.", 364 + "ntfyChannelHintEnabled": "Envoyer les notifications d’espace de travail correspondantes vers ntfy.", 365 + "ntfyChannelHintDisabled": "Configurez et activez d’abord ntfy globalement.", 366 + "webhookChannelHintEnabled": "Envoyer les notifications d’espace de travail correspondantes vers votre webhook.", 367 + "webhookChannelHintDisabled": "Configurez et activez d’abord le webhook globalement.", 368 + "projectScope": "Portée des projets", 369 + "projectScopeDescription": "Tous les projets sont inclus par défaut. Restreignez si cet espace de travail ne doit envoyer des notifications que pour certains projets.", 370 + "allProjects": "Tous les projets", 371 + "allProjectsDescription": "Livrer les notifications de chaque projet de cet espace de travail.", 372 + "selectedProjects": "Projets sélectionnés", 373 + "selectedProjectsDescription": "Ne livrer les notifications que pour les projets choisis.", 374 + "noProjectsInWorkspace": "Aucun projet disponible pour cet espace de travail pour le moment.", 375 + "createRule": "Créer une règle", 376 + "removeRule": "Supprimer la règle", 377 + "toastEmailSaved": "Paramètres de notification par e-mail enregistrés", 378 + "toastEmailSaveFailed": "Échec de l’enregistrement des paramètres e-mail", 379 + "toastNtfySaved": "Paramètres ntfy enregistrés", 380 + "toastNtfySaveFailed": "Échec de l’enregistrement des paramètres ntfy", 381 + "toastNtfyDisconnected": "ntfy déconnecté", 382 + "toastNtfyDisconnectFailed": "Échec de la déconnexion de ntfy", 383 + "toastWebhookSaved": "Paramètres du webhook enregistrés", 384 + "toastWebhookSaveFailed": "Échec de l’enregistrement des paramètres du webhook", 385 + "toastWebhookDisconnected": "Webhook déconnecté", 386 + "toastWebhookDisconnectFailed": "Échec de la déconnexion du webhook", 387 + "toastRuleSaved": "Règle de notification enregistrée pour {{workspaceName}}", 388 + "toastRuleSaveFailed": "Échec de l’enregistrement de la règle de notification de l’espace de travail", 389 + "toastRuleRemoved": "Règle de notification supprimée pour {{workspaceName}}", 390 + "toastRuleRemoveFailed": "Échec de la suppression de la règle de notification de l’espace de travail" 321 391 }, 322 392 "preferencesPage": { 323 393 "title": "Préférences",
+70
i18n/mk-MK.json
··· 298 298 "account": "Сметка", 299 299 "developer": "Програмер", 300 300 "information": "Информации", 301 + "notifications": "Известувања", 301 302 "preferences": "Преференции", 302 303 "apiKeys": "API клучеви", 303 304 "informationPage": { ··· 318 319 "nameShort": "Името мора да има најмалку 2 знаци", 319 320 "invalidEmail": "Невалидна е-пошта" 320 321 } 322 + }, 323 + "notificationsPage": { 324 + "pageTitle": "Известувања", 325 + "title": "Известувања", 326 + "subtitle": "Одбери како Kaneo ги испорачува известувањата за сметката и кои канали да се користат.", 327 + "statusConnected": "Поврзано", 328 + "statusPaused": "Паузирано", 329 + "emailTitle": "Е-пошта", 330 + "emailDescription": "Користи ја е-поштата на сметката како одредиште за известувања.", 331 + "accountEmailLabel": "Е-пошта на сметката", 332 + "accountEmailNoAddress": "Нема достапна е-пошта на сметката", 333 + "accountEmailHint": "Испораката по е-пошта секогаш оди на е-поштата на најавената сметка.", 334 + "saveChanges": "Зачувај промени", 335 + "disconnect": "Исклучи", 336 + "ntfyTitle": "ntfy", 337 + "ntfyDescription": "Објавувај известувања за сметката на ntfy тема.", 338 + "serverUrl": "URL на сервер", 339 + "topic": "Тема", 340 + "token": "Токен", 341 + "ntfyServerPlaceholder": "https://ntfy.example.com", 342 + "ntfyTopicPlaceholder": "team-alerts", 343 + "ntfyTokenPlaceholder": "Опционален bearer токен", 344 + "ntfyTokenHintConfigured": "Токенот веќе е конфигуриран ({{masked}}). Внеси нов за да го замениш.", 345 + "ntfyTokenHintOptional": "Опционално. Дај токен ако ntfy серверот бара автентикација.", 346 + "connectNtfy": "Поврзи ntfy", 347 + "webhookTitle": "Прилагоден webhook", 348 + "webhookDescription": "Испрати известувања за сметката до сопствениот endpoint како JSON.", 349 + "endpointUrl": "URL на endpoint", 350 + "signingSecret": "Тајна за потпис", 351 + "webhookUrlPlaceholder": "https://example.com/webhooks/kaneo", 352 + "webhookSecretPlaceholder": "Опционална заедничка тајна", 353 + "webhookSecretHintConfigured": "Тајна за потпис веќе е конфигурирана ({{masked}}). Внеси нова за да ја замениш.", 354 + "webhookSecretHintOptional": "Опционално. Kaneo го потпишува телото на барањето кога е поставена тајна.", 355 + "connectWebhook": "Поврзи webhook", 356 + "workspaceRulesTitle": "Правила за испорака по работен простор", 357 + "workspaceRulesDescription": "Повторно користи глобални канали, па одреди кои работни простори и проекти смеат да испраќаат известувања за сметката.", 358 + "workspaceCardHint": "Одбери кои канали овој работен простор може да ги користи за известувања за сметката.", 359 + "workspaceCardLabelEmail": "Е-пошта", 360 + "workspaceCardLabelNtfy": "ntfy", 361 + "workspaceCardLabelWebhook": "Прилагоден webhook", 362 + "emailChannelHintEnabled": "Испрати соодветни известувања за работниот простор по е-пошта.", 363 + "emailChannelHintDisabled": "Прво конфигурирај и овозможи е-пошта глобално.", 364 + "ntfyChannelHintEnabled": "Испрати соодветни известувања за работниот простор до ntfy.", 365 + "ntfyChannelHintDisabled": "Прво конфигурирај и овозможи ntfy глобално.", 366 + "webhookChannelHintEnabled": "Испрати соодветни известувања за работниот простор до твојот webhook.", 367 + "webhookChannelHintDisabled": "Прво конфигурирај и овозможи webhook глобално.", 368 + "projectScope": "Опсег на проекти", 369 + "projectScopeDescription": "Сите проекти се вклучени по подразбирање. Сузи ако овој работен простор треба да испраќа известувања само од избрани проекти.", 370 + "allProjects": "Сите проекти", 371 + "allProjectsDescription": "Испорачи известувања од секој проект во овој работен простор.", 372 + "selectedProjects": "Избрани проекти", 373 + "selectedProjectsDescription": "Испорачи известувања само од избрани проекти.", 374 + "noProjectsInWorkspace": "Сè уште нема достапни проекти за овој работен простор.", 375 + "createRule": "Создај правило", 376 + "removeRule": "Отстрани правило", 377 + "toastEmailSaved": "Зачувани се поставките за известувања по е-пошта", 378 + "toastEmailSaveFailed": "Не успеа зачувувањето на поставките за е-пошта", 379 + "toastNtfySaved": "Зачувани се ntfy поставките", 380 + "toastNtfySaveFailed": "Не успеа зачувувањето на ntfy поставките", 381 + "toastNtfyDisconnected": "ntfy е исклучен", 382 + "toastNtfyDisconnectFailed": "Не успеа исклучувањето на ntfy", 383 + "toastWebhookSaved": "Зачувани се webhook поставките", 384 + "toastWebhookSaveFailed": "Не успеа зачувувањето на webhook поставките", 385 + "toastWebhookDisconnected": "Webhook е исклучен", 386 + "toastWebhookDisconnectFailed": "Не успеа исклучувањето на webhook", 387 + "toastRuleSaved": "Зачувано е правилото за известување за {{workspaceName}}", 388 + "toastRuleSaveFailed": "Не успеа зачувувањето на правилото за известување за работниот простор", 389 + "toastRuleRemoved": "Отстрането е правилото за известување за {{workspaceName}}", 390 + "toastRuleRemoveFailed": "Не успеа отстранувањето на правилото за известување за работниот простор" 321 391 }, 322 392 "preferencesPage": { 323 393 "title": "Преференции",
+768 -1
i18n/schema.json
··· 1183 1183 "information": { 1184 1184 "type": "string" 1185 1185 }, 1186 + "notifications": { 1187 + "type": "string" 1188 + }, 1186 1189 "preferences": { 1187 1190 "type": "string" 1188 1191 }, ··· 1262 1265 "validation" 1263 1266 ] 1264 1267 }, 1268 + "notificationsPage": { 1269 + "type": "object", 1270 + "additionalProperties": false, 1271 + "properties": { 1272 + "pageTitle": { 1273 + "type": "string" 1274 + }, 1275 + "title": { 1276 + "type": "string" 1277 + }, 1278 + "subtitle": { 1279 + "type": "string" 1280 + }, 1281 + "statusConnected": { 1282 + "type": "string" 1283 + }, 1284 + "statusPaused": { 1285 + "type": "string" 1286 + }, 1287 + "emailTitle": { 1288 + "type": "string" 1289 + }, 1290 + "emailDescription": { 1291 + "type": "string" 1292 + }, 1293 + "accountEmailLabel": { 1294 + "type": "string" 1295 + }, 1296 + "accountEmailNoAddress": { 1297 + "type": "string" 1298 + }, 1299 + "accountEmailHint": { 1300 + "type": "string" 1301 + }, 1302 + "saveChanges": { 1303 + "type": "string" 1304 + }, 1305 + "disconnect": { 1306 + "type": "string" 1307 + }, 1308 + "ntfyTitle": { 1309 + "type": "string" 1310 + }, 1311 + "ntfyDescription": { 1312 + "type": "string" 1313 + }, 1314 + "serverUrl": { 1315 + "type": "string" 1316 + }, 1317 + "topic": { 1318 + "type": "string" 1319 + }, 1320 + "token": { 1321 + "type": "string" 1322 + }, 1323 + "ntfyServerPlaceholder": { 1324 + "type": "string" 1325 + }, 1326 + "ntfyTopicPlaceholder": { 1327 + "type": "string" 1328 + }, 1329 + "ntfyTokenPlaceholder": { 1330 + "type": "string" 1331 + }, 1332 + "ntfyTokenHintConfigured": { 1333 + "type": "string" 1334 + }, 1335 + "ntfyTokenHintOptional": { 1336 + "type": "string" 1337 + }, 1338 + "connectNtfy": { 1339 + "type": "string" 1340 + }, 1341 + "webhookTitle": { 1342 + "type": "string" 1343 + }, 1344 + "webhookDescription": { 1345 + "type": "string" 1346 + }, 1347 + "endpointUrl": { 1348 + "type": "string" 1349 + }, 1350 + "signingSecret": { 1351 + "type": "string" 1352 + }, 1353 + "webhookUrlPlaceholder": { 1354 + "type": "string" 1355 + }, 1356 + "webhookSecretPlaceholder": { 1357 + "type": "string" 1358 + }, 1359 + "webhookSecretHintConfigured": { 1360 + "type": "string" 1361 + }, 1362 + "webhookSecretHintOptional": { 1363 + "type": "string" 1364 + }, 1365 + "connectWebhook": { 1366 + "type": "string" 1367 + }, 1368 + "workspaceRulesTitle": { 1369 + "type": "string" 1370 + }, 1371 + "workspaceRulesDescription": { 1372 + "type": "string" 1373 + }, 1374 + "workspaceCardHint": { 1375 + "type": "string" 1376 + }, 1377 + "workspaceCardLabelEmail": { 1378 + "type": "string" 1379 + }, 1380 + "workspaceCardLabelNtfy": { 1381 + "type": "string" 1382 + }, 1383 + "workspaceCardLabelWebhook": { 1384 + "type": "string" 1385 + }, 1386 + "emailChannelHintEnabled": { 1387 + "type": "string" 1388 + }, 1389 + "emailChannelHintDisabled": { 1390 + "type": "string" 1391 + }, 1392 + "ntfyChannelHintEnabled": { 1393 + "type": "string" 1394 + }, 1395 + "ntfyChannelHintDisabled": { 1396 + "type": "string" 1397 + }, 1398 + "webhookChannelHintEnabled": { 1399 + "type": "string" 1400 + }, 1401 + "webhookChannelHintDisabled": { 1402 + "type": "string" 1403 + }, 1404 + "projectScope": { 1405 + "type": "string" 1406 + }, 1407 + "projectScopeDescription": { 1408 + "type": "string" 1409 + }, 1410 + "allProjects": { 1411 + "type": "string" 1412 + }, 1413 + "allProjectsDescription": { 1414 + "type": "string" 1415 + }, 1416 + "selectedProjects": { 1417 + "type": "string" 1418 + }, 1419 + "selectedProjectsDescription": { 1420 + "type": "string" 1421 + }, 1422 + "noProjectsInWorkspace": { 1423 + "type": "string" 1424 + }, 1425 + "createRule": { 1426 + "type": "string" 1427 + }, 1428 + "removeRule": { 1429 + "type": "string" 1430 + }, 1431 + "toastEmailSaved": { 1432 + "type": "string" 1433 + }, 1434 + "toastEmailSaveFailed": { 1435 + "type": "string" 1436 + }, 1437 + "toastNtfySaved": { 1438 + "type": "string" 1439 + }, 1440 + "toastNtfySaveFailed": { 1441 + "type": "string" 1442 + }, 1443 + "toastNtfyDisconnected": { 1444 + "type": "string" 1445 + }, 1446 + "toastNtfyDisconnectFailed": { 1447 + "type": "string" 1448 + }, 1449 + "toastWebhookSaved": { 1450 + "type": "string" 1451 + }, 1452 + "toastWebhookSaveFailed": { 1453 + "type": "string" 1454 + }, 1455 + "toastWebhookDisconnected": { 1456 + "type": "string" 1457 + }, 1458 + "toastWebhookDisconnectFailed": { 1459 + "type": "string" 1460 + }, 1461 + "toastRuleSaved": { 1462 + "type": "string" 1463 + }, 1464 + "toastRuleSaveFailed": { 1465 + "type": "string" 1466 + }, 1467 + "toastRuleRemoved": { 1468 + "type": "string" 1469 + }, 1470 + "toastRuleRemoveFailed": { 1471 + "type": "string" 1472 + } 1473 + }, 1474 + "required": [ 1475 + "pageTitle", 1476 + "title", 1477 + "subtitle", 1478 + "statusConnected", 1479 + "statusPaused", 1480 + "emailTitle", 1481 + "emailDescription", 1482 + "accountEmailLabel", 1483 + "accountEmailNoAddress", 1484 + "accountEmailHint", 1485 + "saveChanges", 1486 + "disconnect", 1487 + "ntfyTitle", 1488 + "ntfyDescription", 1489 + "serverUrl", 1490 + "topic", 1491 + "token", 1492 + "ntfyServerPlaceholder", 1493 + "ntfyTopicPlaceholder", 1494 + "ntfyTokenPlaceholder", 1495 + "ntfyTokenHintConfigured", 1496 + "ntfyTokenHintOptional", 1497 + "connectNtfy", 1498 + "webhookTitle", 1499 + "webhookDescription", 1500 + "endpointUrl", 1501 + "signingSecret", 1502 + "webhookUrlPlaceholder", 1503 + "webhookSecretPlaceholder", 1504 + "webhookSecretHintConfigured", 1505 + "webhookSecretHintOptional", 1506 + "connectWebhook", 1507 + "workspaceRulesTitle", 1508 + "workspaceRulesDescription", 1509 + "workspaceCardHint", 1510 + "workspaceCardLabelEmail", 1511 + "workspaceCardLabelNtfy", 1512 + "workspaceCardLabelWebhook", 1513 + "emailChannelHintEnabled", 1514 + "emailChannelHintDisabled", 1515 + "ntfyChannelHintEnabled", 1516 + "ntfyChannelHintDisabled", 1517 + "webhookChannelHintEnabled", 1518 + "webhookChannelHintDisabled", 1519 + "projectScope", 1520 + "projectScopeDescription", 1521 + "allProjects", 1522 + "allProjectsDescription", 1523 + "selectedProjects", 1524 + "selectedProjectsDescription", 1525 + "noProjectsInWorkspace", 1526 + "createRule", 1527 + "removeRule", 1528 + "toastEmailSaved", 1529 + "toastEmailSaveFailed", 1530 + "toastNtfySaved", 1531 + "toastNtfySaveFailed", 1532 + "toastNtfyDisconnected", 1533 + "toastNtfyDisconnectFailed", 1534 + "toastWebhookSaved", 1535 + "toastWebhookSaveFailed", 1536 + "toastWebhookDisconnected", 1537 + "toastWebhookDisconnectFailed", 1538 + "toastRuleSaved", 1539 + "toastRuleSaveFailed", 1540 + "toastRuleRemoved", 1541 + "toastRuleRemoveFailed" 1542 + ] 1543 + }, 1265 1544 "preferencesPage": { 1266 1545 "type": "object", 1267 1546 "additionalProperties": false, ··· 1951 2230 }, 1952 2231 "githubSectionSubtitle": { 1953 2232 "type": "string" 2233 + }, 2234 + "discordSectionTitle": { 2235 + "type": "string" 2236 + }, 2237 + "discordSectionSubtitle": { 2238 + "type": "string" 2239 + }, 2240 + "genericWebhookSectionTitle": { 2241 + "type": "string" 2242 + }, 2243 + "genericWebhookSectionSubtitle": { 2244 + "type": "string" 2245 + }, 2246 + "slackSectionTitle": { 2247 + "type": "string" 2248 + }, 2249 + "slackSectionSubtitle": { 2250 + "type": "string" 1954 2251 } 1955 2252 }, 1956 2253 "required": [ ··· 1958 2255 "title", 1959 2256 "subtitle", 1960 2257 "githubSectionTitle", 1961 - "githubSectionSubtitle" 2258 + "githubSectionSubtitle", 2259 + "discordSectionTitle", 2260 + "discordSectionSubtitle", 2261 + "genericWebhookSectionTitle", 2262 + "genericWebhookSectionSubtitle", 2263 + "slackSectionTitle", 2264 + "slackSectionSubtitle" 1962 2265 ] 1963 2266 }, 1964 2267 "projectVisibility": { ··· 2392 2695 "importDisabledHint" 2393 2696 ] 2394 2697 }, 2698 + "slackIntegration": { 2699 + "type": "object", 2700 + "additionalProperties": false, 2701 + "properties": { 2702 + "validation": { 2703 + "type": "object", 2704 + "additionalProperties": false, 2705 + "properties": { 2706 + "webhookInvalid": { 2707 + "type": "string" 2708 + } 2709 + }, 2710 + "required": ["webhookInvalid"] 2711 + }, 2712 + "toast": { 2713 + "type": "object", 2714 + "additionalProperties": false, 2715 + "properties": { 2716 + "saved": { 2717 + "type": "string" 2718 + }, 2719 + "saveError": { 2720 + "type": "string" 2721 + }, 2722 + "enabled": { 2723 + "type": "string" 2724 + }, 2725 + "disabled": { 2726 + "type": "string" 2727 + }, 2728 + "updateError": { 2729 + "type": "string" 2730 + }, 2731 + "removed": { 2732 + "type": "string" 2733 + }, 2734 + "removeError": { 2735 + "type": "string" 2736 + } 2737 + }, 2738 + "required": [ 2739 + "saved", 2740 + "saveError", 2741 + "enabled", 2742 + "disabled", 2743 + "updateError", 2744 + "removed", 2745 + "removeError" 2746 + ] 2747 + }, 2748 + "connectionTitle": { 2749 + "type": "string" 2750 + }, 2751 + "connectionHint": { 2752 + "type": "string" 2753 + }, 2754 + "connected": { 2755 + "type": "string" 2756 + }, 2757 + "paused": { 2758 + "type": "string" 2759 + }, 2760 + "webhookLabel": { 2761 + "type": "string" 2762 + }, 2763 + "webhookPlaceholder": { 2764 + "type": "string" 2765 + }, 2766 + "webhookHint": { 2767 + "type": "string" 2768 + }, 2769 + "channelLabel": { 2770 + "type": "string" 2771 + }, 2772 + "channelPlaceholder": { 2773 + "type": "string" 2774 + }, 2775 + "channelHint": { 2776 + "type": "string" 2777 + }, 2778 + "eventsTitle": { 2779 + "type": "string" 2780 + }, 2781 + "eventsHint": { 2782 + "type": "string" 2783 + }, 2784 + "events": { 2785 + "type": "object", 2786 + "additionalProperties": false, 2787 + "properties": { 2788 + "taskCreated": { 2789 + "type": "string" 2790 + }, 2791 + "taskStatusChanged": { 2792 + "type": "string" 2793 + }, 2794 + "taskPriorityChanged": { 2795 + "type": "string" 2796 + }, 2797 + "taskTitleChanged": { 2798 + "type": "string" 2799 + }, 2800 + "taskDescriptionChanged": { 2801 + "type": "string" 2802 + }, 2803 + "taskCommentCreated": { 2804 + "type": "string" 2805 + } 2806 + }, 2807 + "required": [ 2808 + "taskCreated", 2809 + "taskStatusChanged", 2810 + "taskPriorityChanged", 2811 + "taskTitleChanged", 2812 + "taskDescriptionChanged", 2813 + "taskCommentCreated" 2814 + ] 2815 + }, 2816 + "connect": { 2817 + "type": "string" 2818 + }, 2819 + "saveChanges": { 2820 + "type": "string" 2821 + }, 2822 + "update": { 2823 + "type": "string" 2824 + }, 2825 + "disconnect": { 2826 + "type": "string" 2827 + } 2828 + }, 2829 + "required": [ 2830 + "validation", 2831 + "toast", 2832 + "connectionTitle", 2833 + "connectionHint", 2834 + "connected", 2835 + "paused", 2836 + "webhookLabel", 2837 + "webhookPlaceholder", 2838 + "webhookHint", 2839 + "channelLabel", 2840 + "channelPlaceholder", 2841 + "channelHint", 2842 + "eventsTitle", 2843 + "eventsHint", 2844 + "events", 2845 + "connect", 2846 + "saveChanges", 2847 + "update", 2848 + "disconnect" 2849 + ] 2850 + }, 2851 + "discordIntegration": { 2852 + "type": "object", 2853 + "additionalProperties": false, 2854 + "properties": { 2855 + "validation": { 2856 + "type": "object", 2857 + "additionalProperties": false, 2858 + "properties": { 2859 + "webhookInvalid": { 2860 + "type": "string" 2861 + } 2862 + }, 2863 + "required": ["webhookInvalid"] 2864 + }, 2865 + "toast": { 2866 + "type": "object", 2867 + "additionalProperties": false, 2868 + "properties": { 2869 + "saved": { 2870 + "type": "string" 2871 + }, 2872 + "saveError": { 2873 + "type": "string" 2874 + }, 2875 + "enabled": { 2876 + "type": "string" 2877 + }, 2878 + "disabled": { 2879 + "type": "string" 2880 + }, 2881 + "updateError": { 2882 + "type": "string" 2883 + }, 2884 + "removed": { 2885 + "type": "string" 2886 + }, 2887 + "removeError": { 2888 + "type": "string" 2889 + } 2890 + }, 2891 + "required": [ 2892 + "saved", 2893 + "saveError", 2894 + "enabled", 2895 + "disabled", 2896 + "updateError", 2897 + "removed", 2898 + "removeError" 2899 + ] 2900 + }, 2901 + "connectionTitle": { 2902 + "type": "string" 2903 + }, 2904 + "connectionHint": { 2905 + "type": "string" 2906 + }, 2907 + "connected": { 2908 + "type": "string" 2909 + }, 2910 + "paused": { 2911 + "type": "string" 2912 + }, 2913 + "webhookLabel": { 2914 + "type": "string" 2915 + }, 2916 + "webhookPlaceholder": { 2917 + "type": "string" 2918 + }, 2919 + "webhookHint": { 2920 + "type": "string" 2921 + }, 2922 + "channelLabel": { 2923 + "type": "string" 2924 + }, 2925 + "channelPlaceholder": { 2926 + "type": "string" 2927 + }, 2928 + "channelHint": { 2929 + "type": "string" 2930 + }, 2931 + "eventsTitle": { 2932 + "type": "string" 2933 + }, 2934 + "eventsHint": { 2935 + "type": "string" 2936 + }, 2937 + "events": { 2938 + "type": "object", 2939 + "additionalProperties": false, 2940 + "properties": { 2941 + "taskCreated": { 2942 + "type": "string" 2943 + }, 2944 + "taskStatusChanged": { 2945 + "type": "string" 2946 + }, 2947 + "taskPriorityChanged": { 2948 + "type": "string" 2949 + }, 2950 + "taskTitleChanged": { 2951 + "type": "string" 2952 + }, 2953 + "taskDescriptionChanged": { 2954 + "type": "string" 2955 + }, 2956 + "taskCommentCreated": { 2957 + "type": "string" 2958 + } 2959 + }, 2960 + "required": [ 2961 + "taskCreated", 2962 + "taskStatusChanged", 2963 + "taskPriorityChanged", 2964 + "taskTitleChanged", 2965 + "taskDescriptionChanged", 2966 + "taskCommentCreated" 2967 + ] 2968 + }, 2969 + "connect": { 2970 + "type": "string" 2971 + }, 2972 + "saveChanges": { 2973 + "type": "string" 2974 + }, 2975 + "update": { 2976 + "type": "string" 2977 + }, 2978 + "disconnect": { 2979 + "type": "string" 2980 + } 2981 + }, 2982 + "required": [ 2983 + "validation", 2984 + "toast", 2985 + "connectionTitle", 2986 + "connectionHint", 2987 + "connected", 2988 + "paused", 2989 + "webhookLabel", 2990 + "webhookPlaceholder", 2991 + "webhookHint", 2992 + "channelLabel", 2993 + "channelPlaceholder", 2994 + "channelHint", 2995 + "eventsTitle", 2996 + "eventsHint", 2997 + "events", 2998 + "connect", 2999 + "saveChanges", 3000 + "update", 3001 + "disconnect" 3002 + ] 3003 + }, 3004 + "genericWebhookIntegration": { 3005 + "type": "object", 3006 + "additionalProperties": false, 3007 + "properties": { 3008 + "validation": { 3009 + "type": "object", 3010 + "additionalProperties": false, 3011 + "properties": { 3012 + "webhookInvalid": { 3013 + "type": "string" 3014 + } 3015 + }, 3016 + "required": ["webhookInvalid"] 3017 + }, 3018 + "toast": { 3019 + "type": "object", 3020 + "additionalProperties": false, 3021 + "properties": { 3022 + "saved": { 3023 + "type": "string" 3024 + }, 3025 + "saveError": { 3026 + "type": "string" 3027 + }, 3028 + "enabled": { 3029 + "type": "string" 3030 + }, 3031 + "disabled": { 3032 + "type": "string" 3033 + }, 3034 + "updateError": { 3035 + "type": "string" 3036 + }, 3037 + "removed": { 3038 + "type": "string" 3039 + }, 3040 + "removeError": { 3041 + "type": "string" 3042 + } 3043 + }, 3044 + "required": [ 3045 + "saved", 3046 + "saveError", 3047 + "enabled", 3048 + "disabled", 3049 + "updateError", 3050 + "removed", 3051 + "removeError" 3052 + ] 3053 + }, 3054 + "connectionTitle": { 3055 + "type": "string" 3056 + }, 3057 + "connectionHint": { 3058 + "type": "string" 3059 + }, 3060 + "connected": { 3061 + "type": "string" 3062 + }, 3063 + "paused": { 3064 + "type": "string" 3065 + }, 3066 + "webhookLabel": { 3067 + "type": "string" 3068 + }, 3069 + "webhookPlaceholder": { 3070 + "type": "string" 3071 + }, 3072 + "webhookHint": { 3073 + "type": "string" 3074 + }, 3075 + "secretLabel": { 3076 + "type": "string" 3077 + }, 3078 + "secretPlaceholder": { 3079 + "type": "string" 3080 + }, 3081 + "secretHint": { 3082 + "type": "string" 3083 + }, 3084 + "secretHintConfigured": { 3085 + "type": "string" 3086 + }, 3087 + "eventsTitle": { 3088 + "type": "string" 3089 + }, 3090 + "eventsHint": { 3091 + "type": "string" 3092 + }, 3093 + "events": { 3094 + "type": "object", 3095 + "additionalProperties": false, 3096 + "properties": { 3097 + "taskCreated": { 3098 + "type": "string" 3099 + }, 3100 + "taskStatusChanged": { 3101 + "type": "string" 3102 + }, 3103 + "taskPriorityChanged": { 3104 + "type": "string" 3105 + }, 3106 + "taskTitleChanged": { 3107 + "type": "string" 3108 + }, 3109 + "taskDescriptionChanged": { 3110 + "type": "string" 3111 + }, 3112 + "taskCommentCreated": { 3113 + "type": "string" 3114 + } 3115 + }, 3116 + "required": [ 3117 + "taskCreated", 3118 + "taskStatusChanged", 3119 + "taskPriorityChanged", 3120 + "taskTitleChanged", 3121 + "taskDescriptionChanged", 3122 + "taskCommentCreated" 3123 + ] 3124 + }, 3125 + "connect": { 3126 + "type": "string" 3127 + }, 3128 + "saveChanges": { 3129 + "type": "string" 3130 + }, 3131 + "disconnect": { 3132 + "type": "string" 3133 + } 3134 + }, 3135 + "required": [ 3136 + "validation", 3137 + "toast", 3138 + "connectionTitle", 3139 + "connectionHint", 3140 + "connected", 3141 + "paused", 3142 + "webhookLabel", 3143 + "webhookPlaceholder", 3144 + "webhookHint", 3145 + "secretLabel", 3146 + "secretPlaceholder", 3147 + "secretHint", 3148 + "secretHintConfigured", 3149 + "eventsTitle", 3150 + "eventsHint", 3151 + "events", 3152 + "connect", 3153 + "saveChanges", 3154 + "disconnect" 3155 + ] 3156 + }, 2395 3157 "repositoryBrowser": { 2396 3158 "type": "object", 2397 3159 "additionalProperties": false, ··· 2646 3408 "account", 2647 3409 "developer", 2648 3410 "information", 3411 + "notifications", 2649 3412 "preferences", 2650 3413 "apiKeys", 2651 3414 "informationPage", 3415 + "notificationsPage", 2652 3416 "preferencesPage", 2653 3417 "developerPage", 2654 3418 "apiKey", ··· 2660 3424 "projectSwitcher", 2661 3425 "columnEditor", 2662 3426 "githubIntegration", 3427 + "slackIntegration", 3428 + "discordIntegration", 3429 + "genericWebhookIntegration", 2663 3430 "repositoryBrowser", 2664 3431 "tasksImportExport", 2665 3432 "workflowEditor",
+1
packages/email/src/index.tsx
··· 1 1 export { 2 2 sendMagicLinkEmail, 3 + sendNotificationEmail, 3 4 sendOtpEmail, 4 5 sendPasswordResetEmail, 5 6 sendWorkspaceInvitationEmail,
+27
packages/email/src/send-email.tsx
··· 3 3 import * as nodemailer from "nodemailer"; 4 4 import type { MagicLinkEmailProps } from "./templates/magic-link"; 5 5 import MagicLinkEmail from "./templates/magic-link"; 6 + import NotificationEmail, { 7 + type NotificationEmailProps, 8 + } from "./templates/notification"; 6 9 import type { OtpEmailProps } from "./templates/otp"; 7 10 import OtpEmail from "./templates/otp"; 8 11 import PasswordResetEmail, { ··· 110 113 throw error; 111 114 } 112 115 }; 116 + 117 + export const sendNotificationEmail = async ( 118 + to: string, 119 + subject: string, 120 + data: NotificationEmailProps, 121 + ): Promise<EmailResult> => { 122 + if (!process.env.SMTP_HOST || !process.env.SMTP_FROM) { 123 + return { success: false, reason: "SMTP_NOT_CONFIGURED" }; 124 + } 125 + 126 + try { 127 + const emailTemplate = await render(NotificationEmail(data)); 128 + await transporter.sendMail({ 129 + from: process.env.SMTP_FROM, 130 + to, 131 + subject, 132 + html: emailTemplate, 133 + }); 134 + return { success: true }; 135 + } catch (error) { 136 + console.error("Error sending notification email", error); 137 + throw error; 138 + } 139 + };
+63
packages/email/src/templates/notification.tsx
··· 1 + import { Link, Section, Text } from "@react-email/components"; 2 + import React from "react"; 3 + import { resolveEmailLocale } from "./resolve-locale"; 4 + import { EmailShell, styles } from "./shell"; 5 + 6 + void React; 7 + 8 + export type NotificationEmailProps = { 9 + title: string; 10 + message: string; 11 + actionUrl?: string | null; 12 + actionLabel?: string; 13 + locale?: string | null; 14 + }; 15 + 16 + const messages = { 17 + en: { 18 + preview: "You have a new Kaneo notification", 19 + subtitle: "A notification matched your delivery preferences.", 20 + footer: "Kaneo notification", 21 + actionLabel: "Open in Kaneo", 22 + }, 23 + de: { 24 + preview: "Du hast eine neue Kaneo-Benachrichtigung", 25 + subtitle: 26 + "Eine Benachrichtigung entspricht deinen Zustellungs-Einstellungen.", 27 + footer: "Kaneo-Benachrichtigung", 28 + actionLabel: "In Kaneo oeffnen", 29 + }, 30 + } as const; 31 + 32 + const NotificationEmail = ({ 33 + title, 34 + message, 35 + actionUrl, 36 + actionLabel, 37 + locale, 38 + }: NotificationEmailProps) => { 39 + const copy = messages[resolveEmailLocale(locale)]; 40 + 41 + return ( 42 + <EmailShell preview={copy.preview} title={title} subtitle={copy.subtitle}> 43 + <Section> 44 + <Text style={styles.paragraph}>{message}</Text> 45 + {actionUrl ? ( 46 + <Link style={styles.button} href={actionUrl}> 47 + {actionLabel ?? copy.actionLabel} 48 + </Link> 49 + ) : null} 50 + <Section style={styles.divider} /> 51 + <Text style={styles.footer}>{copy.footer}</Text> 52 + </Section> 53 + </EmailShell> 54 + ); 55 + }; 56 + 57 + NotificationEmail.PreviewProps = { 58 + title: "Task assigned to you", 59 + message: "You were assigned to Design account notifications.", 60 + actionUrl: "https://kaneo.app", 61 + } as NotificationEmailProps; 62 + 63 + export default NotificationEmail;