@checkstack/notification-backend 0.2.1 → 1.0.1
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/CHANGELOG.md +179 -0
- package/drizzle/0005_material_mauler.sql +3 -0
- package/drizzle/0006_chubby_gladiator.sql +46 -0
- package/drizzle/meta/0005_snapshot.json +237 -0
- package/drizzle/meta/0006_snapshot.json +532 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +12 -11
- package/src/router.ts +605 -97
- package/src/schema.ts +155 -14
- package/src/subscription-engine.ts +257 -0
- package/tsconfig.json +36 -1
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
.
|
|
473
|
-
.
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const { inArray } = await import("drizzle-orm");
|
|
721
|
+
const legacyResolver = target.legacyGroupIdTemplate
|
|
722
|
+
? makeLegacyResolver(target.legacyGroupIdTemplate)
|
|
723
|
+
: undefined;
|
|
515
724
|
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
.
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
.
|
|
541
|
-
.
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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: "
|
|
1177
|
+
importance: notification.importance ?? "info",
|
|
670
1178
|
action: notification.action,
|
|
671
1179
|
type: "transactional",
|
|
672
1180
|
};
|