@open-mercato/core 0.10.1-develop.7767.2.440f7c2b4c → 0.10.1-develop.7769.1.0e6458bb99
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/dist/modules/api_keys/data/entities.js.map +1 -1
- package/dist/modules/api_keys/services/apiKeyService.js +1 -0
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/auth/lib/authorizationStateLocks.js +13 -5
- package/dist/modules/auth/lib/authorizationStateLocks.js.map +2 -2
- package/dist/modules/auth/services/rbacService.js +68 -11
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/dist/modules/directory/utils/organizationScope.js +6 -3
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/notifications/lib/notificationService.js +19 -1
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/data/entities.ts +1 -1
- package/src/modules/api_keys/services/apiKeyService.ts +2 -0
- package/src/modules/auth/lib/authorizationStateLocks.ts +13 -7
- package/src/modules/auth/services/rbacService.ts +86 -9
- package/src/modules/directory/utils/organizationScope.ts +6 -3
- package/src/modules/notifications/lib/notificationService.ts +24 -2
|
@@ -195,7 +195,6 @@ export async function lockAuthorizationState(
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
const apiKeyIds = uniqueSortedIds(targets.apiKeyIds ?? [])
|
|
198
|
-
const userIds = uniqueSortedIds(targets.userIds ?? [])
|
|
199
198
|
const explicitRoleIds = uniqueSortedIds(targets.roleIds ?? [])
|
|
200
199
|
const tenantIds = uniqueSortedIds(targets.tenantIds ?? [])
|
|
201
200
|
const explicitApiKeys = apiKeyIds.length
|
|
@@ -207,9 +206,16 @@ export async function lockAuthorizationState(
|
|
|
207
206
|
{ tenantId: null, organizationId: null },
|
|
208
207
|
)
|
|
209
208
|
: []
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
209
|
+
const explicitApiKeySnapshots = new Map(explicitApiKeys.map((apiKey) => [String(apiKey.id), {
|
|
210
|
+
sessionUserId: apiKey.sessionUserId ?? null,
|
|
211
|
+
roles: Array.isArray(apiKey.rolesJson) ? [...apiKey.rolesJson] : [],
|
|
212
|
+
}]))
|
|
213
|
+
const apiKeySnapshots = [...explicitApiKeySnapshots.values()]
|
|
214
|
+
const userIds = uniqueSortedIds([
|
|
215
|
+
...(targets.userIds ?? []),
|
|
216
|
+
...apiKeySnapshots.flatMap((apiKey) => apiKey.sessionUserId ? [apiKey.sessionUserId] : []),
|
|
217
|
+
])
|
|
218
|
+
const explicitApiKeyRoleIds = uniqueSortedIds(apiKeySnapshots.flatMap((apiKey) => apiKey.roles))
|
|
213
219
|
const discoveredBeforeLocks = userIds.length
|
|
214
220
|
? await findWithDecryption(
|
|
215
221
|
em,
|
|
@@ -236,10 +242,10 @@ export async function lockAuthorizationState(
|
|
|
236
242
|
for (const apiKey of lockedApiKeys) {
|
|
237
243
|
const id = String(apiKey.id)
|
|
238
244
|
if (!apiKeyIds.includes(id)) continue
|
|
239
|
-
const before =
|
|
240
|
-
const beforeRoles =
|
|
245
|
+
const before = explicitApiKeySnapshots.get(id)
|
|
246
|
+
const beforeRoles = before?.roles ?? []
|
|
241
247
|
const lockedRoles = Array.isArray(apiKey.rolesJson) ? apiKey.rolesJson : []
|
|
242
|
-
if (!sameIds(beforeRoles, lockedRoles)) {
|
|
248
|
+
if (!sameIds(beforeRoles, lockedRoles) || (before?.sessionUserId ?? null) !== (apiKey.sessionUserId ?? null)) {
|
|
243
249
|
throw authorizationStateDrift()
|
|
244
250
|
}
|
|
245
251
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
resolveEffectiveFeatures,
|
|
14
14
|
} from '@open-mercato/shared/security/featurePolicy'
|
|
15
15
|
import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
|
|
16
|
+
import { hasFeature } from '@open-mercato/shared/security/features'
|
|
16
17
|
import { resolveRoleOrganizationScope, roleAclAllowsOrganization } from './roleOrganizationScope'
|
|
17
18
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
18
19
|
|
|
@@ -24,6 +25,27 @@ interface AclData {
|
|
|
24
25
|
organizations: string[] | null
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
function intersectDelegatedAcl(keyAcl: AclData, ownerAcl: AclData): AclData {
|
|
29
|
+
const keyFeatures = keyAcl.isSuperAdmin ? ['*'] : keyAcl.features
|
|
30
|
+
const ownerFeatures = ownerAcl.isSuperAdmin ? ['*'] : ownerAcl.features
|
|
31
|
+
const features = Array.from(new Set([
|
|
32
|
+
...keyFeatures.filter((feature) => hasFeature(ownerFeatures, feature)),
|
|
33
|
+
...ownerFeatures.filter((feature) => hasFeature(keyFeatures, feature)),
|
|
34
|
+
]))
|
|
35
|
+
const keyOrganizations = keyAcl.isSuperAdmin || keyAcl.organizations?.includes('__all__')
|
|
36
|
+
? null : keyAcl.organizations
|
|
37
|
+
const ownerOrganizations = ownerAcl.isSuperAdmin || ownerAcl.organizations?.includes('__all__')
|
|
38
|
+
? null : ownerAcl.organizations
|
|
39
|
+
const organizations = keyOrganizations === null ? ownerOrganizations
|
|
40
|
+
: ownerOrganizations === null ? keyOrganizations
|
|
41
|
+
: keyOrganizations.filter((organizationId) => ownerOrganizations.includes(organizationId))
|
|
42
|
+
return {
|
|
43
|
+
isSuperAdmin: keyAcl.isSuperAdmin && ownerAcl.isSuperAdmin,
|
|
44
|
+
features: organizations?.length === 0 ? [] : features,
|
|
45
|
+
organizations,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
async function resolveRoleOrganizationScopeWithEntityManager(
|
|
28
50
|
em: EntityManager,
|
|
29
51
|
tenantId: string | null,
|
|
@@ -81,7 +103,7 @@ type FeatureOrganizationRoleGrant = {
|
|
|
81
103
|
|
|
82
104
|
type FeatureOrganizationGrants =
|
|
83
105
|
| { kind: 'none' }
|
|
84
|
-
| { kind: 'api_key'; organizationId: string | null; roleGrants: FeatureOrganizationRoleGrant[] }
|
|
106
|
+
| { kind: 'api_key'; organizationId: string | null; ownerId: string | null; roleGrants: FeatureOrganizationRoleGrant[] }
|
|
85
107
|
| { kind: 'user_acl'; isSuperAdmin: boolean; features: string[]; organizations: string[] | null }
|
|
86
108
|
| { kind: 'roles'; roleGrants: FeatureOrganizationRoleGrant[] }
|
|
87
109
|
|
|
@@ -105,6 +127,7 @@ function isFeatureOrganizationGrants(value: unknown): value is FeatureOrganizati
|
|
|
105
127
|
return true
|
|
106
128
|
case 'api_key':
|
|
107
129
|
return (record.organizationId === null || typeof record.organizationId === 'string')
|
|
130
|
+
&& (record.ownerId === null || typeof record.ownerId === 'string')
|
|
108
131
|
&& Array.isArray(record.roleGrants)
|
|
109
132
|
&& record.roleGrants.every(isFeatureOrganizationRoleGrant)
|
|
110
133
|
case 'user_acl':
|
|
@@ -260,7 +283,8 @@ export class RbacService {
|
|
|
260
283
|
}
|
|
261
284
|
|
|
262
285
|
private getCacheKey(userId: string, scope: { tenantId: string | null; organizationId: string | null }): string {
|
|
263
|
-
|
|
286
|
+
const namespace = userId.startsWith('api_key:') ? 'rbac:delegated-v1' : 'rbac'
|
|
287
|
+
return `${namespace}:${userId}:${scope.tenantId || 'null'}:${scope.organizationId || 'null'}`
|
|
264
288
|
}
|
|
265
289
|
|
|
266
290
|
private getUserTag(userId: string): string {
|
|
@@ -282,12 +306,12 @@ export class RbacService {
|
|
|
282
306
|
return isAclData(cached) ? cached : null
|
|
283
307
|
}
|
|
284
308
|
|
|
285
|
-
private async setCache(cacheKey: string, data: AclData, userId: string, scope: { tenantId: string | null; organizationId: string | null }): Promise<void> {
|
|
309
|
+
private async setCache(cacheKey: string, data: AclData, userId: string, scope: { tenantId: string | null; organizationId: string | null }, ownerId?: string | null): Promise<void> {
|
|
286
310
|
if (!this.cache) return
|
|
287
311
|
|
|
288
312
|
await this.cache.set(cacheKey, data, {
|
|
289
313
|
ttl: this.cacheTtlMs,
|
|
290
|
-
tags: this.buildCacheTags(userId, scope),
|
|
314
|
+
tags: [...this.buildCacheTags(userId, scope), ...(ownerId ? [this.getUserTag(ownerId)] : [])],
|
|
291
315
|
})
|
|
292
316
|
}
|
|
293
317
|
|
|
@@ -584,8 +608,9 @@ export class RbacService {
|
|
|
584
608
|
// the key is restricted so downstream super-admin shortcuts cannot
|
|
585
609
|
// bypass the key or role organization bounds.
|
|
586
610
|
if (isSuper && organizations !== null && !features.includes('*')) features.push('*')
|
|
587
|
-
const
|
|
588
|
-
|
|
611
|
+
const keyAcl = { isSuperAdmin: isSuper && organizations === null, features, organizations }
|
|
612
|
+
const result = await this.limitDelegatedKeyAcl(em, key, { tenantId, organizationId: evaluatedOrganizationId }, keyAcl)
|
|
613
|
+
if (cacheable) await this.setCache(cacheKey, result, userId, { tenantId, organizationId: evaluatedOrganizationId }, key.sessionUserId)
|
|
589
614
|
return result
|
|
590
615
|
}
|
|
591
616
|
|
|
@@ -795,7 +820,13 @@ export class RbacService {
|
|
|
795
820
|
: []
|
|
796
821
|
}
|
|
797
822
|
if (isSuperAdmin && organizations !== null && !features.includes('*')) features.push('*')
|
|
798
|
-
return
|
|
823
|
+
return this.limitDelegatedKeyAcl(
|
|
824
|
+
em,
|
|
825
|
+
key,
|
|
826
|
+
{ tenantId, organizationId: evaluatedOrganizationId },
|
|
827
|
+
{ isSuperAdmin: isSuperAdmin && organizations === null, features, organizations },
|
|
828
|
+
true,
|
|
829
|
+
)
|
|
799
830
|
}
|
|
800
831
|
|
|
801
832
|
const user = await findOneWithDecryption(
|
|
@@ -997,6 +1028,33 @@ export class RbacService {
|
|
|
997
1028
|
})
|
|
998
1029
|
}
|
|
999
1030
|
|
|
1031
|
+
private async limitDelegatedKeyAcl(
|
|
1032
|
+
em: EntityManager,
|
|
1033
|
+
key: ApiKey,
|
|
1034
|
+
scope: { tenantId: string | null; organizationId: string | null },
|
|
1035
|
+
keyAcl: AclData,
|
|
1036
|
+
uncached = false,
|
|
1037
|
+
): Promise<AclData> {
|
|
1038
|
+
if (!key.sessionUserId) return keyAcl
|
|
1039
|
+
if (!key.tenantId || key.tenantId !== scope.tenantId) {
|
|
1040
|
+
return { isSuperAdmin: false, features: [], organizations: [] }
|
|
1041
|
+
}
|
|
1042
|
+
const owner = await findOneWithDecryption(em, User, {
|
|
1043
|
+
id: key.sessionUserId,
|
|
1044
|
+
tenantId: key.tenantId,
|
|
1045
|
+
deletedAt: null,
|
|
1046
|
+
}, undefined, scope)
|
|
1047
|
+
if (!owner) return { isSuperAdmin: false, features: [], organizations: [] }
|
|
1048
|
+
const ownerAcl = uncached
|
|
1049
|
+
? await this.loadAclWithEntityManager(em, key.sessionUserId, scope)
|
|
1050
|
+
: await this.loadAcl(key.sessionUserId, scope)
|
|
1051
|
+
const result = intersectDelegatedAcl(keyAcl, ownerAcl)
|
|
1052
|
+
if (scope.organizationId && result.organizations && !result.organizations.includes(scope.organizationId)) {
|
|
1053
|
+
return { ...result, isSuperAdmin: false, features: [] }
|
|
1054
|
+
}
|
|
1055
|
+
return result
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1000
1058
|
async resolveFeatureOrganizationAccess(
|
|
1001
1059
|
userId: string,
|
|
1002
1060
|
required: readonly string[],
|
|
@@ -1029,13 +1087,18 @@ export class RbacService {
|
|
|
1029
1087
|
|
|
1030
1088
|
if (grants.kind === 'api_key') {
|
|
1031
1089
|
const keyOrganizationId = grants.organizationId
|
|
1090
|
+
const ownerAccess = grants.ownerId
|
|
1091
|
+
? await this.resolveFeatureOrganizationAccess(grants.ownerId, required, input)
|
|
1092
|
+
: { unrestricted: true, filterOrganizationIds: allowAll }
|
|
1032
1093
|
const unrestricted = keyOrganizationId === null
|
|
1094
|
+
&& ownerAccess.unrestricted
|
|
1033
1095
|
&& roleAclsAuthorizeFeaturesGlobally(grants.roleGrants, required, true)
|
|
1034
1096
|
if (unrestricted) return { unrestricted: true, filterOrganizationIds: allowAll }
|
|
1035
1097
|
|
|
1036
1098
|
return {
|
|
1037
1099
|
unrestricted: false,
|
|
1038
1100
|
filterOrganizationIds: (candidates) => normalizeFeatureOrganizationCandidates(candidates)
|
|
1101
|
+
.filter((organization) => ownerAccess.filterOrganizationIds([organization]).length > 0)
|
|
1039
1102
|
.filter((organization) => (
|
|
1040
1103
|
(!keyOrganizationId || organization.id === keyOrganizationId)
|
|
1041
1104
|
&& roleAclsAuthorizeFeatures(grants.roleGrants, required, organization, true)
|
|
@@ -1091,7 +1154,7 @@ export class RbacService {
|
|
|
1091
1154
|
userId: string,
|
|
1092
1155
|
tenantId: string,
|
|
1093
1156
|
): Promise<FeatureOrganizationGrants> {
|
|
1094
|
-
const cacheKey = `rbac:feature-organizations:${userId}:${tenantId}`
|
|
1157
|
+
const cacheKey = `rbac:feature-organizations:delegated-v1:${userId}:${tenantId}`
|
|
1095
1158
|
const key = userId.startsWith('api_key:')
|
|
1096
1159
|
? await this.em.fork().findOne(ApiKey, { id: userId.slice('api_key:'.length), deletedAt: null })
|
|
1097
1160
|
: undefined
|
|
@@ -1104,7 +1167,10 @@ export class RbacService {
|
|
|
1104
1167
|
if (this.cache && cacheable) {
|
|
1105
1168
|
await this.cache.set(cacheKey, grants, {
|
|
1106
1169
|
ttl: this.cacheTtlMs,
|
|
1107
|
-
tags:
|
|
1170
|
+
tags: [
|
|
1171
|
+
...this.buildCacheTags(userId, { tenantId, organizationId: null }),
|
|
1172
|
+
...(grants.kind === 'api_key' && grants.ownerId ? [this.getUserTag(grants.ownerId)] : []),
|
|
1173
|
+
],
|
|
1108
1174
|
})
|
|
1109
1175
|
}
|
|
1110
1176
|
return grants
|
|
@@ -1129,6 +1195,16 @@ export class RbacService {
|
|
|
1129
1195
|
return { kind: 'none' }
|
|
1130
1196
|
}
|
|
1131
1197
|
|
|
1198
|
+
if (key.sessionUserId) {
|
|
1199
|
+
if (key.tenantId !== tenantId) return { kind: 'none' }
|
|
1200
|
+
const owner = await findOneWithDecryption(em, User, {
|
|
1201
|
+
id: key.sessionUserId,
|
|
1202
|
+
tenantId,
|
|
1203
|
+
deletedAt: null,
|
|
1204
|
+
}, undefined, { tenantId, organizationId: null })
|
|
1205
|
+
if (!owner) return { kind: 'none' }
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1132
1208
|
const roleIds = await resolveApiKeyRoleIds(em, key, tenantId)
|
|
1133
1209
|
if (!roleIds.length) return { kind: 'none' }
|
|
1134
1210
|
const roleAcls = await em.find(RoleAcl, {
|
|
@@ -1142,6 +1218,7 @@ export class RbacService {
|
|
|
1142
1218
|
return {
|
|
1143
1219
|
kind: 'api_key',
|
|
1144
1220
|
organizationId: keyOrganizationId,
|
|
1221
|
+
ownerId: key.sessionUserId ?? null,
|
|
1145
1222
|
roleGrants: roleAcls.map(toFeatureOrganizationRoleGrant),
|
|
1146
1223
|
}
|
|
1147
1224
|
}
|
|
@@ -49,7 +49,8 @@ function buildOrgScopeCacheKey(parts: {
|
|
|
49
49
|
}): string {
|
|
50
50
|
const selected = parts.selectedOrgId ?? 'none'
|
|
51
51
|
const requested = parts.requestedTenantId ?? 'none'
|
|
52
|
-
|
|
52
|
+
const namespace = parts.userId.startsWith('api_key:') ? `${ORG_SCOPE_CACHE_KEY_PREFIX}:delegated-v1` : ORG_SCOPE_CACHE_KEY_PREFIX
|
|
53
|
+
return `${namespace}:${parts.userId}:${parts.effectiveTenantId}:${selected}:${requested}`
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
// Tag builders are exported so the modules that own the "this user's scope
|
|
@@ -65,10 +66,11 @@ export function buildOrgScopeTenantCacheTag(tenantId: string): string {
|
|
|
65
66
|
return `${ORG_SCOPE_CACHE_KEY_PREFIX}:tenant:${tenantId}`
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
function buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string }): string[] {
|
|
69
|
+
function buildOrgScopeCacheTags(parts: { userId: string; effectiveTenantId: string; ownerUserId: string | null }): string[] {
|
|
69
70
|
return [
|
|
70
71
|
buildOrgScopeUserCacheTag(parts.userId),
|
|
71
72
|
buildOrgScopeTenantCacheTag(parts.effectiveTenantId),
|
|
73
|
+
...(parts.ownerUserId ? [buildOrgScopeUserCacheTag(parts.ownerUserId)] : []),
|
|
72
74
|
]
|
|
73
75
|
}
|
|
74
76
|
|
|
@@ -488,6 +490,7 @@ export async function resolveOrganizationScopeForRequest({
|
|
|
488
490
|
: null
|
|
489
491
|
|
|
490
492
|
const userId = typeof auth.sub === 'string' && auth.sub.length > 0 ? auth.sub : null
|
|
493
|
+
const ownerUserId = auth.isApiKey && typeof auth.userId === 'string' && auth.userId.length > 0 ? auth.userId : null
|
|
491
494
|
const ttlMs = resolveOrgScopeTtlMs()
|
|
492
495
|
const cache = ttlMs > 0 ? resolveCacheFromContainer(container) : null
|
|
493
496
|
const cacheKey = userId
|
|
@@ -532,7 +535,7 @@ export async function resolveOrganizationScopeForRequest({
|
|
|
532
535
|
try {
|
|
533
536
|
await crossRequestCache.set(cacheKey, baseScope, {
|
|
534
537
|
ttl: ttlMs,
|
|
535
|
-
tags: buildOrgScopeCacheTags({ userId, effectiveTenantId }),
|
|
538
|
+
tags: buildOrgScopeCacheTags({ userId, effectiveTenantId, ownerUserId }),
|
|
536
539
|
})
|
|
537
540
|
} catch (err) {
|
|
538
541
|
logger.warn('Cache write failed', { err })
|
|
@@ -268,9 +268,13 @@ export type CreateFeatureNotificationServiceInput = CreateFeatureNotificationInp
|
|
|
268
268
|
restrictRecipientsToOrganization?: boolean
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
export type CreateBatchNotificationOptions = {
|
|
272
|
+
skipExistingGroup?: boolean
|
|
273
|
+
}
|
|
274
|
+
|
|
271
275
|
export interface NotificationService {
|
|
272
276
|
create(input: CreateNotificationInput, ctx: NotificationServiceContext): Promise<Notification>
|
|
273
|
-
createBatch(input: CreateBatchNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>
|
|
277
|
+
createBatch(input: CreateBatchNotificationInput, ctx: NotificationServiceContext, options?: CreateBatchNotificationOptions): Promise<Notification[]>
|
|
274
278
|
createForRole(input: CreateRoleNotificationInput, ctx: NotificationServiceContext): Promise<Notification[]>
|
|
275
279
|
createForFeature(input: CreateFeatureNotificationServiceInput, ctx: NotificationServiceContext): Promise<Notification[]>
|
|
276
280
|
markAsRead(notificationId: string, ctx: NotificationServiceContext): Promise<Notification>
|
|
@@ -436,8 +440,12 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
|
|
|
436
440
|
return notification
|
|
437
441
|
},
|
|
438
442
|
|
|
439
|
-
async createBatch(input, ctx) {
|
|
443
|
+
async createBatch(input, ctx, options) {
|
|
444
|
+
if (options?.skipExistingGroup && !input.groupKey?.trim()) {
|
|
445
|
+
throw new Error('[internal] Notification group key is required for deduplicated batch delivery')
|
|
446
|
+
}
|
|
440
447
|
const recipientUserIds = Array.from(new Set(input.recipientUserIds))
|
|
448
|
+
if (options?.skipExistingGroup) recipientUserIds.sort((left, right) => left.localeCompare(right))
|
|
441
449
|
const { recipientUserIds: _recipientUserIds, ...content } = input
|
|
442
450
|
const notifications: Notification[] = []
|
|
443
451
|
const resolved = await resolveChannelsForRecipients(content, recipientUserIds, ctx)
|
|
@@ -446,12 +454,26 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
|
|
|
446
454
|
await writeEm.transactional(async (tx) => {
|
|
447
455
|
await assertNotificationRecipientsInScope(tx, recipientUserIds, ctx)
|
|
448
456
|
for (const { recipientUserId, channels } of resolved) {
|
|
457
|
+
if (options?.skipExistingGroup) {
|
|
458
|
+
const organizationId = normalizeOrgScope(ctx.organizationId)
|
|
459
|
+
const lockKey = `notifications:${ctx.tenantId}:${organizationId ?? 'global'}:${recipientUserId}:${content.type}:${content.groupKey}`
|
|
460
|
+
await sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`.execute(getDb(tx))
|
|
461
|
+
const existing = await tx.findOne(Notification, {
|
|
462
|
+
recipientUserId,
|
|
463
|
+
tenantId: ctx.tenantId,
|
|
464
|
+
organizationId,
|
|
465
|
+
type: content.type,
|
|
466
|
+
groupKey: content.groupKey,
|
|
467
|
+
})
|
|
468
|
+
if (existing) continue
|
|
469
|
+
}
|
|
449
470
|
const notification = await createOrRefreshNotification(tx, content, recipientUserId, ctx, channels)
|
|
450
471
|
notifications.push(notification)
|
|
451
472
|
}
|
|
452
473
|
await tx.flush()
|
|
453
474
|
})
|
|
454
475
|
|
|
476
|
+
if (options?.skipExistingGroup && notifications.length === 0) return []
|
|
455
477
|
await invalidateNotificationCache(container, ctx, 'created')
|
|
456
478
|
await emitNotificationCreatedBatch(eventBus, notifications, ctx)
|
|
457
479
|
await emitNotificationSseEvents(eventBus, notifications, ctx)
|