@checkstack/notification-backend 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/router.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { implement, ORPCError } from "@orpc/server";
2
+ import { and, eq, inArray, sql } from "drizzle-orm";
2
3
  import {
3
4
  autoAuthMiddleware,
4
5
  type RpcContext,
@@ -31,6 +32,13 @@ import {
31
32
  subscribeToGroup,
32
33
  unsubscribeFromGroup,
33
34
  } from "./service";
35
+ import {
36
+ deriveGroupId,
37
+ provisionGroupsForResource,
38
+ provisionGroupsForSpec,
39
+ resolveInheritedGroupIds,
40
+ teardownGroupsForResource,
41
+ } from "./subscription-engine";
34
42
  import {
35
43
  retentionConfigV1,
36
44
  RETENTION_CONFIG_VERSION,
@@ -84,6 +92,42 @@ function resolveContact({
84
92
  }
85
93
  }
86
94
 
95
+ /**
96
+ * Substitute `{resourceKey}` in a stored legacy template to get the
97
+ * actual legacy groupId to seed from.
98
+ */
99
+ function makeLegacyResolver(template: string): (resourceKey: string) => string {
100
+ return (resourceKey: string) =>
101
+ template.replaceAll('{resourceKey}', resourceKey);
102
+ }
103
+
104
+ /**
105
+ * Look up a target type and ensure the calling plugin owns it.
106
+ * Centralizes the ownership check used by every target-mutating RPC.
107
+ */
108
+ async function assertTargetOwnedBy(opts: {
109
+ db: SafeDatabase<typeof schema>;
110
+ targetTypeId: string;
111
+ callerPluginId: string;
112
+ }): Promise<typeof schema.notificationTargets.$inferSelect> {
113
+ const [target] = await opts.db
114
+ .select()
115
+ .from(schema.notificationTargets)
116
+ .where(eq(schema.notificationTargets.targetTypeId, opts.targetTypeId))
117
+ .limit(1);
118
+ if (!target) {
119
+ throw new ORPCError("NOT_FOUND", {
120
+ message: `Notification target ${opts.targetTypeId} is not registered`,
121
+ });
122
+ }
123
+ if (target.ownerPlugin !== opts.callerPluginId) {
124
+ throw new ORPCError("FORBIDDEN", {
125
+ message: `Plugin ${opts.callerPluginId} cannot mutate target ${opts.targetTypeId} (owned by ${target.ownerPlugin})`,
126
+ });
127
+ }
128
+ return target;
129
+ }
130
+
87
131
  /**
88
132
  * Creates the notification router using contract-based implementation.
89
133
  *
@@ -117,6 +161,7 @@ export const createNotificationRouter = (
117
161
  body?: string;
118
162
  importance: string;
119
163
  action?: { label: string; url: string };
164
+ subjects?: NotificationPayload["subjects"];
120
165
  }
121
166
  ): Promise<void> => {
122
167
  const authClient = rpcApi.forPlugin(AuthApi);
@@ -217,6 +262,7 @@ export const createNotificationRouter = (
217
262
  | "warning"
218
263
  | "critical",
219
264
  action: notification.action,
265
+ subjects: notification.subjects,
220
266
  type: "notification",
221
267
  };
222
268
 
@@ -287,7 +333,8 @@ export const createNotificationRouter = (
287
333
  action: n.action ?? undefined,
288
334
  importance: n.importance as "info" | "warning" | "critical",
289
335
  isRead: n.isRead,
290
- groupId: n.groupId ?? undefined,
336
+ collapseKey: n.collapseKey ?? undefined,
337
+ subjects: n.subjects ?? undefined,
291
338
  createdAt: n.createdAt,
292
339
  })),
293
340
  total: result.total,
@@ -371,6 +418,32 @@ export const createNotificationRouter = (
371
418
  await cache.invalidateSubscriptions(userId);
372
419
  }),
373
420
 
421
+ getMySubscriptionStatus: os.getMySubscriptionStatus.handler(
422
+ async ({ input, context }) => {
423
+ const userId = (context.user as RealUser).id;
424
+ if (input.groupIds.length === 0) return {};
425
+
426
+ const { eq, and, inArray } = await import("drizzle-orm");
427
+ const rows = await database
428
+ .select({ groupId: schema.notificationSubscriptions.groupId })
429
+ .from(schema.notificationSubscriptions)
430
+ .where(
431
+ and(
432
+ eq(schema.notificationSubscriptions.userId, userId),
433
+ inArray(
434
+ schema.notificationSubscriptions.groupId,
435
+ input.groupIds,
436
+ ),
437
+ ),
438
+ );
439
+
440
+ const subscribed = new Set(rows.map((r) => r.groupId));
441
+ return Object.fromEntries(
442
+ input.groupIds.map((id) => [id, subscribed.has(id)]),
443
+ );
444
+ },
445
+ ),
446
+
374
447
  // ==========================================================================
375
448
  // ADMIN SETTINGS ENDPOINTS
376
449
  // Contract meta: userType: "user", accessRules: [notificationAdmin]
@@ -452,127 +525,562 @@ export const createNotificationRouter = (
452
525
  return { userIds: subscribers.map((s) => s.userId) };
453
526
  }),
454
527
 
455
- notifyUsers: os.notifyUsers.handler(async ({ input }) => {
456
- const { userIds, title, body, importance, action } = input;
457
-
458
- if (userIds.length === 0) {
459
- return { notifiedCount: 0 };
528
+ bulkSubscribe: os.bulkSubscribe.handler(async ({ input }) => {
529
+ if (input.userIds.length === 0) {
530
+ return { subscribedCount: 0 };
460
531
  }
461
532
 
462
- const notificationValues = userIds.map((userId) => ({
463
- userId,
464
- title,
465
- body,
466
- action,
467
- importance: importance ?? "info",
533
+ const inserted = await database
534
+ .insert(schema.notificationSubscriptions)
535
+ .values(
536
+ input.userIds.map((userId) => ({
537
+ userId,
538
+ groupId: input.groupId,
539
+ })),
540
+ )
541
+ .onConflictDoNothing()
542
+ .returning({ userId: schema.notificationSubscriptions.userId });
543
+
544
+ return { subscribedCount: inserted.length };
545
+ }),
546
+
547
+ registerNotificationTarget: os.registerNotificationTarget.handler(
548
+ async ({ input, context }) => {
549
+ const caller = context.user as { type: string; pluginId?: string };
550
+ if (caller.type !== "service" || !caller.pluginId) {
551
+ throw new ORPCError("FORBIDDEN", {
552
+ message:
553
+ "registerNotificationTarget is only callable from a service",
554
+ });
555
+ }
556
+ if (caller.pluginId !== input.ownerPlugin) {
557
+ throw new ORPCError("FORBIDDEN", {
558
+ message: `Plugin ${caller.pluginId} cannot register a target owned by ${input.ownerPlugin}`,
559
+ });
560
+ }
561
+ await database
562
+ .insert(schema.notificationTargets)
563
+ .values({
564
+ targetTypeId: input.targetTypeId,
565
+ ownerPlugin: input.ownerPlugin,
566
+ resourceKind: input.resourceKind,
567
+ parentTargetTypeId: input.parentTargetTypeId,
568
+ legacyGroupIdTemplate: input.legacyGroupIdTemplate,
569
+ })
570
+ .onConflictDoUpdate({
571
+ target: [schema.notificationTargets.targetTypeId],
572
+ set: {
573
+ ownerPlugin: input.ownerPlugin,
574
+ resourceKind: input.resourceKind,
575
+ parentTargetTypeId: input.parentTargetTypeId,
576
+ legacyGroupIdTemplate: input.legacyGroupIdTemplate,
577
+ },
578
+ });
579
+ return { success: true };
580
+ },
581
+ ),
582
+
583
+ listNotificationTargets: os.listNotificationTargets.handler(async () => {
584
+ const rows = await database.select().from(schema.notificationTargets);
585
+ return rows.map((row) => ({
586
+ targetTypeId: row.targetTypeId,
587
+ ownerPlugin: row.ownerPlugin,
588
+ resourceKind: row.resourceKind,
589
+ parentTargetTypeId: row.parentTargetTypeId ?? undefined,
590
+ legacyGroupIdTemplate: row.legacyGroupIdTemplate ?? undefined,
468
591
  }));
592
+ }),
469
593
 
470
- const inserted = await database
471
- .insert(schema.notifications)
472
- .values(notificationValues)
473
- .returning({
474
- id: schema.notifications.id,
475
- userId: schema.notifications.userId,
594
+ upsertNotificationResource: os.upsertNotificationResource.handler(
595
+ async ({ input, context }) => {
596
+ const caller = context.user as { type: string; pluginId?: string };
597
+ if (caller.type !== "service" || !caller.pluginId) {
598
+ throw new ORPCError("FORBIDDEN", {
599
+ message:
600
+ "upsertNotificationResource is only callable from a service",
601
+ });
602
+ }
603
+ const target = await assertTargetOwnedBy({
604
+ db: database,
605
+ targetTypeId: input.targetTypeId,
606
+ callerPluginId: caller.pluginId,
476
607
  });
477
608
 
478
- // Drop each affected user's cache before signaling, so the
479
- // NotificationBell's refetch sees the new unread count.
480
- await Promise.all(
481
- userIds.map((userId) => cache.invalidateForUser(userId)),
482
- );
609
+ await database
610
+ .insert(schema.notificationResources)
611
+ .values({
612
+ targetTypeId: input.targetTypeId,
613
+ resourceKey: input.resource.resourceKey,
614
+ displayLabel: input.resource.displayLabel,
615
+ })
616
+ .onConflictDoUpdate({
617
+ target: [
618
+ schema.notificationResources.targetTypeId,
619
+ schema.notificationResources.resourceKey,
620
+ ],
621
+ set: {
622
+ displayLabel: input.resource.displayLabel,
623
+ upsertedAt: new Date(),
624
+ },
625
+ });
483
626
 
484
- // Send realtime signals to each user
485
- for (const notification of inserted) {
486
- void signalService.sendToUser(
487
- NOTIFICATION_RECEIVED,
488
- notification.userId,
489
- {
490
- id: notification.id,
491
- title,
492
- body,
493
- importance: importance ?? "info",
627
+ const [refreshed] = await database
628
+ .select()
629
+ .from(schema.notificationResources)
630
+ .where(
631
+ and(
632
+ eq(schema.notificationResources.targetTypeId, input.targetTypeId),
633
+ eq(
634
+ schema.notificationResources.resourceKey,
635
+ input.resource.resourceKey,
636
+ ),
637
+ ),
638
+ )
639
+ .limit(1);
640
+ if (refreshed) {
641
+ await provisionGroupsForResource({
642
+ db: database,
643
+ targetTypeId: input.targetTypeId,
644
+ resource: refreshed,
645
+ });
646
+ // Run legacy migration seed for any spec freshly bound to this
647
+ // resource (idempotent — subscription_migrations gates it).
648
+ if (target.legacyGroupIdTemplate) {
649
+ const specs = await database
650
+ .select()
651
+ .from(schema.subscriptionSpecs)
652
+ .where(
653
+ eq(schema.subscriptionSpecs.targetTypeId, input.targetTypeId),
654
+ );
655
+ for (const spec of specs) {
656
+ await provisionGroupsForSpec({
657
+ db: database,
658
+ spec,
659
+ legacyGroupIdFor: makeLegacyResolver(
660
+ target.legacyGroupIdTemplate,
661
+ ),
662
+ });
663
+ }
494
664
  }
495
- );
496
- }
665
+ }
666
+ return { success: true };
667
+ },
668
+ ),
497
669
 
498
- // Also send to external channels (Telegram, SMTP, etc.) - fire and forget
499
- for (const userId of userIds) {
500
- void sendToExternalChannels(userId, {
501
- title,
502
- body,
503
- importance: importance ?? "info",
504
- action,
670
+ upsertNotificationResources: os.upsertNotificationResources.handler(
671
+ async ({ input, context }) => {
672
+ const caller = context.user as { type: string; pluginId?: string };
673
+ if (caller.type !== "service" || !caller.pluginId) {
674
+ throw new ORPCError("FORBIDDEN", {
675
+ message:
676
+ "upsertNotificationResources is only callable from a service",
677
+ });
678
+ }
679
+ const target = await assertTargetOwnedBy({
680
+ db: database,
681
+ targetTypeId: input.targetTypeId,
682
+ callerPluginId: caller.pluginId,
505
683
  });
506
- }
684
+ if (input.resources.length === 0) return { upserted: 0 };
685
+
686
+ await database
687
+ .insert(schema.notificationResources)
688
+ .values(
689
+ input.resources.map((r) => ({
690
+ targetTypeId: input.targetTypeId,
691
+ resourceKey: r.resourceKey,
692
+ displayLabel: r.displayLabel,
693
+ })),
694
+ )
695
+ .onConflictDoUpdate({
696
+ target: [
697
+ schema.notificationResources.targetTypeId,
698
+ schema.notificationResources.resourceKey,
699
+ ],
700
+ set: {
701
+ displayLabel: sql`excluded.display_label`,
702
+ upsertedAt: new Date(),
703
+ },
704
+ });
507
705
 
508
- return { notifiedCount: userIds.length };
509
- }),
706
+ // Provision groups for every spec already registered against this
707
+ // target. This is the path catalog hits on platform startup —
708
+ // each plugin then registers its spec and the spec-side
709
+ // provisioning loop seeds it from the legacy groups.
710
+ const specs = await database
711
+ .select()
712
+ .from(schema.subscriptionSpecs)
713
+ .where(eq(schema.subscriptionSpecs.targetTypeId, input.targetTypeId));
714
+ const refreshed = await database
715
+ .select()
716
+ .from(schema.notificationResources)
717
+ .where(
718
+ eq(schema.notificationResources.targetTypeId, input.targetTypeId),
719
+ );
510
720
 
511
- // Notify all subscribers of multiple groups with internal deduplication
512
- notifyGroups: os.notifyGroups.handler(async ({ input }) => {
513
- const { groupIds, title, body, importance, action } = input;
514
- const { inArray } = await import("drizzle-orm");
721
+ const legacyResolver = target.legacyGroupIdTemplate
722
+ ? makeLegacyResolver(target.legacyGroupIdTemplate)
723
+ : undefined;
515
724
 
516
- if (groupIds.length === 0) {
517
- return { notifiedCount: 0 };
518
- }
725
+ for (const spec of specs) {
726
+ await provisionGroupsForSpec({
727
+ db: database,
728
+ spec,
729
+ legacyGroupIdFor: legacyResolver,
730
+ });
731
+ }
732
+ // Resources without specs still get tracked (so future spec
733
+ // registrations can bind), but no groups are created for them
734
+ // until at least one spec exists.
735
+ void refreshed;
736
+ return { upserted: input.resources.length };
737
+ },
738
+ ),
519
739
 
520
- // Get all subscribers for all groups, deduplicated
521
- const subscribers = await database
522
- .selectDistinct({ userId: schema.notificationSubscriptions.userId })
523
- .from(schema.notificationSubscriptions)
524
- .where(inArray(schema.notificationSubscriptions.groupId, groupIds));
740
+ removeNotificationResource: os.removeNotificationResource.handler(
741
+ async ({ input, context }) => {
742
+ const caller = context.user as { type: string; pluginId?: string };
743
+ if (caller.type !== "service" || !caller.pluginId) {
744
+ throw new ORPCError("FORBIDDEN", {
745
+ message:
746
+ "removeNotificationResource is only callable from a service",
747
+ });
748
+ }
749
+ await assertTargetOwnedBy({
750
+ db: database,
751
+ targetTypeId: input.targetTypeId,
752
+ callerPluginId: caller.pluginId,
753
+ });
525
754
 
526
- if (subscribers.length === 0) {
527
- return { notifiedCount: 0 };
528
- }
755
+ const removedGroups = await teardownGroupsForResource({
756
+ db: database,
757
+ targetTypeId: input.targetTypeId,
758
+ resourceKey: input.resourceKey,
759
+ });
760
+ await database
761
+ .delete(schema.notificationResources)
762
+ .where(
763
+ and(
764
+ eq(schema.notificationResources.targetTypeId, input.targetTypeId),
765
+ eq(
766
+ schema.notificationResources.resourceKey,
767
+ input.resourceKey,
768
+ ),
769
+ ),
770
+ );
771
+ await database
772
+ .delete(schema.notificationResourceParents)
773
+ .where(
774
+ and(
775
+ eq(
776
+ schema.notificationResourceParents.childTargetTypeId,
777
+ input.targetTypeId,
778
+ ),
779
+ eq(
780
+ schema.notificationResourceParents.childResourceKey,
781
+ input.resourceKey,
782
+ ),
783
+ ),
784
+ );
785
+ return { removedGroups };
786
+ },
787
+ ),
529
788
 
530
- const notificationValues = subscribers.map((sub) => ({
531
- userId: sub.userId,
532
- title,
533
- body,
534
- action,
535
- importance: importance ?? "info",
789
+ listNotificationResources: os.listNotificationResources.handler(
790
+ async ({ input }) => {
791
+ const rows = await database
792
+ .select()
793
+ .from(schema.notificationResources)
794
+ .where(
795
+ eq(schema.notificationResources.targetTypeId, input.targetTypeId),
796
+ );
797
+ return rows.map((r) => ({
798
+ resourceKey: r.resourceKey,
799
+ displayLabel: r.displayLabel,
800
+ }));
801
+ },
802
+ ),
803
+
804
+ setNotificationResourceParents:
805
+ os.setNotificationResourceParents.handler(
806
+ async ({ input, context }) => {
807
+ const caller = context.user as {
808
+ type: string;
809
+ pluginId?: string;
810
+ };
811
+ if (caller.type !== "service" || !caller.pluginId) {
812
+ throw new ORPCError("FORBIDDEN", {
813
+ message:
814
+ "setNotificationResourceParents is only callable from a service",
815
+ });
816
+ }
817
+ await assertTargetOwnedBy({
818
+ db: database,
819
+ targetTypeId: input.childTargetTypeId,
820
+ callerPluginId: caller.pluginId,
821
+ });
822
+
823
+ await database
824
+ .delete(schema.notificationResourceParents)
825
+ .where(
826
+ and(
827
+ eq(
828
+ schema.notificationResourceParents.childTargetTypeId,
829
+ input.childTargetTypeId,
830
+ ),
831
+ eq(
832
+ schema.notificationResourceParents.childResourceKey,
833
+ input.childResourceKey,
834
+ ),
835
+ ),
836
+ );
837
+ if (input.parents.length > 0) {
838
+ await database
839
+ .insert(schema.notificationResourceParents)
840
+ .values(
841
+ input.parents.map((p) => ({
842
+ childTargetTypeId: input.childTargetTypeId,
843
+ childResourceKey: input.childResourceKey,
844
+ parentTargetTypeId: p.parentTargetTypeId,
845
+ parentResourceKey: p.parentResourceKey,
846
+ })),
847
+ )
848
+ .onConflictDoNothing();
849
+ }
850
+ return { success: true };
851
+ },
852
+ ),
853
+
854
+ registerSubscriptionSpec: os.registerSubscriptionSpec.handler(
855
+ async ({ input, context }) => {
856
+ const caller = context.user as { type: string; pluginId?: string };
857
+ if (caller.type !== "service" || !caller.pluginId) {
858
+ throw new ORPCError("FORBIDDEN", {
859
+ message: "registerSubscriptionSpec is only callable from a service",
860
+ });
861
+ }
862
+ if (caller.pluginId !== input.ownerPlugin) {
863
+ throw new ORPCError("FORBIDDEN", {
864
+ message: `Plugin ${caller.pluginId} cannot register specs owned by ${input.ownerPlugin}`,
865
+ });
866
+ }
867
+
868
+ // The target's owner plugin is guaranteed to have completed
869
+ // its init + afterPluginsReady before this point because the
870
+ // plugin loader derives an init-order edge from each declared
871
+ // subscription spec (`spec.target.ownerPlugin`) to the
872
+ // emitting plugin. If the target is missing here, the
873
+ // emitting plugin failed to declare the spec at register
874
+ // time — surfacing a clear error is the right behavior.
875
+ const [target] = await database
876
+ .select()
877
+ .from(schema.notificationTargets)
878
+ .where(
879
+ eq(schema.notificationTargets.targetTypeId, input.targetTypeId),
880
+ )
881
+ .limit(1);
882
+ if (!target) {
883
+ throw new ORPCError("NOT_FOUND", {
884
+ message: `Target type ${input.targetTypeId} is not registered. Did the emitting plugin declare this spec via env.registerSubscriptionSpecs(...) in its register() block?`,
885
+ });
886
+ }
887
+
888
+ await database
889
+ .insert(schema.subscriptionSpecs)
890
+ .values({
891
+ specId: input.specId,
892
+ ownerPlugin: input.ownerPlugin,
893
+ localId: input.localId,
894
+ targetTypeId: input.targetTypeId,
895
+ displayTitle: input.display.title,
896
+ displayDescription: input.display.description,
897
+ displayIconName: input.display.iconName,
898
+ })
899
+ .onConflictDoUpdate({
900
+ target: [schema.subscriptionSpecs.specId],
901
+ set: {
902
+ ownerPlugin: input.ownerPlugin,
903
+ localId: input.localId,
904
+ targetTypeId: input.targetTypeId,
905
+ displayTitle: input.display.title,
906
+ displayDescription: input.display.description,
907
+ displayIconName: input.display.iconName,
908
+ },
909
+ });
910
+
911
+ const [spec] = await database
912
+ .select()
913
+ .from(schema.subscriptionSpecs)
914
+ .where(eq(schema.subscriptionSpecs.specId, input.specId))
915
+ .limit(1);
916
+ if (spec) {
917
+ await provisionGroupsForSpec({
918
+ db: database,
919
+ spec,
920
+ legacyGroupIdFor: target.legacyGroupIdTemplate
921
+ ? makeLegacyResolver(target.legacyGroupIdTemplate)
922
+ : undefined,
923
+ });
924
+ }
925
+ return { success: true };
926
+ },
927
+ ),
928
+
929
+ listSubscriptionSpecs: os.listSubscriptionSpecs.handler(async () => {
930
+ const rows = await database.select().from(schema.subscriptionSpecs);
931
+ return rows.map((row) => ({
932
+ specId: row.specId,
933
+ ownerPlugin: row.ownerPlugin,
934
+ localId: row.localId,
935
+ targetTypeId: row.targetTypeId,
936
+ display: {
937
+ title: row.displayTitle,
938
+ description: row.displayDescription,
939
+ iconName: row.displayIconName ?? undefined,
940
+ },
536
941
  }));
942
+ }),
537
943
 
538
- const inserted = await database
539
- .insert(schema.notifications)
540
- .values(notificationValues)
541
- .returning({
542
- id: schema.notifications.id,
543
- userId: schema.notifications.userId,
544
- });
944
+ notifyForSubscription: os.notifyForSubscription.handler(
945
+ async ({ input, context }) => {
946
+ const caller = context.user as { type: string; pluginId?: string };
947
+ if (caller.type !== "service" || !caller.pluginId) {
948
+ throw new ORPCError("FORBIDDEN", {
949
+ message: "notifyForSubscription is only callable from a service",
950
+ });
951
+ }
545
952
 
546
- await Promise.all(
547
- subscribers.map((sub) => cache.invalidateForUser(sub.userId)),
548
- );
953
+ const [spec] = await database
954
+ .select()
955
+ .from(schema.subscriptionSpecs)
956
+ .where(eq(schema.subscriptionSpecs.specId, input.specId))
957
+ .limit(1);
958
+ if (!spec) {
959
+ throw new ORPCError("NOT_FOUND", {
960
+ message: `Subscription spec ${input.specId} is not registered`,
961
+ });
962
+ }
963
+ if (spec.ownerPlugin !== caller.pluginId) {
964
+ throw new ORPCError("FORBIDDEN", {
965
+ message: `Plugin ${caller.pluginId} cannot dispatch under spec ${input.specId} (owned by ${spec.ownerPlugin})`,
966
+ });
967
+ }
549
968
 
550
- // Send realtime signals to each subscriber
551
- for (const notification of inserted) {
552
- void signalService.sendToUser(
553
- NOTIFICATION_RECEIVED,
554
- notification.userId,
555
- {
556
- id: notification.id,
557
- title,
558
- body,
559
- importance: importance ?? "info",
560
- }
969
+ // Validate every resourceKey is a known resource of the spec's
970
+ // target. Unknown keys = drift (resource was never pushed) and
971
+ // would silently produce zero subscribers.
972
+ const known = await database
973
+ .select({ resourceKey: schema.notificationResources.resourceKey })
974
+ .from(schema.notificationResources)
975
+ .where(
976
+ and(
977
+ eq(
978
+ schema.notificationResources.targetTypeId,
979
+ spec.targetTypeId,
980
+ ),
981
+ inArray(
982
+ schema.notificationResources.resourceKey,
983
+ input.resourceKeys,
984
+ ),
985
+ ),
986
+ );
987
+ const knownSet = new Set(known.map((k) => k.resourceKey));
988
+ const missing = input.resourceKeys.filter((k) => !knownSet.has(k));
989
+ if (missing.length > 0) {
990
+ throw new ORPCError("NOT_FOUND", {
991
+ message: `Resources not registered for target ${spec.targetTypeId}: ${missing.join(", ")}`,
992
+ });
993
+ }
994
+
995
+ // Primary group ids — one per resourceKey.
996
+ const primaryGroupIds = input.resourceKeys.map((rk) =>
997
+ deriveGroupId({
998
+ ownerPlugin: spec.ownerPlugin,
999
+ localId: spec.localId,
1000
+ resourceKey: rk,
1001
+ }),
561
1002
  );
562
- }
563
1003
 
564
- // Also send to external channels (Telegram, SMTP, etc.) - fire and forget
565
- for (const sub of subscribers) {
566
- void sendToExternalChannels(sub.userId, {
1004
+ // Inherited group ids: walk parent edges for each resourceKey,
1005
+ // map parent targets to same-plugin specs.
1006
+ const inheritedGroupIds = new Set<string>();
1007
+ for (const rk of input.resourceKeys) {
1008
+ const parts = await resolveInheritedGroupIds({
1009
+ db: database,
1010
+ spec,
1011
+ resourceKey: rk,
1012
+ });
1013
+ for (const gid of parts) inheritedGroupIds.add(gid);
1014
+ }
1015
+
1016
+ const allGroupIds = [...primaryGroupIds, ...inheritedGroupIds];
1017
+
1018
+ const subscribers = await database
1019
+ .selectDistinct({ userId: schema.notificationSubscriptions.userId })
1020
+ .from(schema.notificationSubscriptions)
1021
+ .where(
1022
+ inArray(
1023
+ schema.notificationSubscriptions.groupId,
1024
+ allGroupIds,
1025
+ ),
1026
+ );
1027
+
1028
+ const excluded = new Set(input.excludeUserIds);
1029
+ const recipients = subscribers
1030
+ .map((s) => s.userId)
1031
+ .filter((uid) => !excluded.has(uid));
1032
+
1033
+ if (recipients.length === 0) {
1034
+ return { notifiedCount: 0 };
1035
+ }
1036
+
1037
+ const { title, body, importance, action, collapseKey, subjects } =
1038
+ input;
1039
+ const notificationValues = recipients.map((userId) => ({
1040
+ userId,
567
1041
  title,
568
1042
  body,
569
- importance: importance ?? "info",
570
1043
  action,
571
- });
572
- }
1044
+ importance: importance ?? "info",
1045
+ collapseKey,
1046
+ subjects,
1047
+ }));
1048
+ const inserted = await database
1049
+ .insert(schema.notifications)
1050
+ .values(notificationValues)
1051
+ .returning({
1052
+ id: schema.notifications.id,
1053
+ userId: schema.notifications.userId,
1054
+ });
573
1055
 
574
- return { notifiedCount: subscribers.length };
575
- }),
1056
+ await Promise.all(
1057
+ recipients.map((userId) => cache.invalidateForUser(userId)),
1058
+ );
1059
+
1060
+ for (const notification of inserted) {
1061
+ void signalService.sendToUser(
1062
+ NOTIFICATION_RECEIVED,
1063
+ notification.userId,
1064
+ {
1065
+ id: notification.id,
1066
+ title,
1067
+ body,
1068
+ importance: importance ?? "info",
1069
+ },
1070
+ );
1071
+ }
1072
+ for (const userId of recipients) {
1073
+ void sendToExternalChannels(userId, {
1074
+ title,
1075
+ body,
1076
+ importance: importance ?? "info",
1077
+ action,
1078
+ subjects,
1079
+ });
1080
+ }
1081
+ return { notifiedCount: recipients.length };
1082
+ },
1083
+ ),
576
1084
 
577
1085
  // Send transactional notification via ALL enabled strategies
578
1086
  // No internal notification created - sent directly via external channels
@@ -666,7 +1174,7 @@ export const createNotificationRouter = (
666
1174
  const payload: NotificationPayload = {
667
1175
  title: notification.title,
668
1176
  body: notification.body,
669
- importance: "critical", // Transactional messages are always critical
1177
+ importance: notification.importance ?? "info",
670
1178
  action: notification.action,
671
1179
  type: "transactional",
672
1180
  };