@open-mercato/ai-assistant 0.6.3-develop.3901.1.ddad60693a → 0.6.3
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.js +87 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.js +119 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.js.map +7 -0
- package/dist/modules/ai_assistant/acl.js +1 -0
- package/dist/modules/ai_assistant/acl.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +3 -0
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.js +128 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.js +271 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js +9 -1
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/conversations/route.js +4 -1
- package/dist/modules/ai_assistant/api/ai/conversations/route.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +5 -1
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/components/ConversationShareButton.js +5 -0
- package/dist/modules/ai_assistant/components/ConversationShareButton.js.map +7 -0
- package/dist/modules/ai_assistant/components/ConversationShareDialog.js +5 -0
- package/dist/modules/ai_assistant/components/ConversationShareDialog.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +3 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js +235 -5
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js.map +2 -2
- package/dist/modules/ai_assistant/events.js +14 -0
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +17 -0
- package/dist/modules/ai_assistant/i18n/en.json +17 -0
- package/dist/modules/ai_assistant/i18n/es.json +17 -0
- package/dist/modules/ai_assistant/i18n/pl.json +17 -0
- package/dist/modules/ai_assistant/lib/conversation-storage.js +12 -3
- package/dist/modules/ai_assistant/lib/conversation-storage.js.map +2 -2
- package/dist/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.js +15 -0
- package/dist/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.js.map +7 -0
- package/dist/modules/ai_assistant/notifications.client.js +30 -0
- package/dist/modules/ai_assistant/notifications.client.js.map +7 -0
- package/dist/modules/ai_assistant/notifications.js +27 -0
- package/dist/modules/ai_assistant/notifications.js.map +7 -0
- package/dist/modules/ai_assistant/setup.js +2 -1
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/dist/modules/ai_assistant/subscribers/conversation-shared-notify.js +59 -0
- package/dist/modules/ai_assistant/subscribers/conversation-shared-notify.js.map +7 -0
- package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js +123 -0
- package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js.map +7 -0
- package/generated/entities/ai_chat_conversation_participant/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -8
- package/src/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.ts +117 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.ts +159 -0
- package/src/modules/ai_assistant/__tests__/integration/ai-chat-sharing.test.ts +406 -0
- package/src/modules/ai_assistant/acl.ts +1 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +3 -0
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.ts +149 -0
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.ts +314 -0
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/route.ts +9 -1
- package/src/modules/ai_assistant/api/ai/conversations/route.ts +4 -1
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -0
- package/src/modules/ai_assistant/components/ConversationShareButton.tsx +1 -0
- package/src/modules/ai_assistant/components/ConversationShareDialog.tsx +1 -0
- package/src/modules/ai_assistant/data/entities.ts +4 -0
- package/src/modules/ai_assistant/data/repositories/AiChatConversationRepository.ts +270 -7
- package/src/modules/ai_assistant/data/repositories/__tests__/AiChatConversationRepository.test.ts +297 -3
- package/src/modules/ai_assistant/events.ts +31 -0
- package/src/modules/ai_assistant/i18n/__tests__/conversation-share-translations.test.ts +59 -0
- package/src/modules/ai_assistant/i18n/de.json +17 -0
- package/src/modules/ai_assistant/i18n/en.json +17 -0
- package/src/modules/ai_assistant/i18n/es.json +17 -0
- package/src/modules/ai_assistant/i18n/pl.json +17 -0
- package/src/modules/ai_assistant/lib/conversation-storage.ts +22 -1
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +25 -0
- package/src/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.ts +15 -0
- package/src/modules/ai_assistant/notifications.client.ts +29 -0
- package/src/modules/ai_assistant/notifications.ts +25 -0
- package/src/modules/ai_assistant/setup.ts +2 -1
- package/src/modules/ai_assistant/subscribers/__tests__/conversation-shared-notify.test.ts +116 -0
- package/src/modules/ai_assistant/subscribers/conversation-shared-notify.ts +78 -0
- package/src/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.tsx +121 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
1
|
+
import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
|
|
2
2
|
import {
|
|
3
3
|
findOneWithDecryption,
|
|
4
4
|
findWithDecryption,
|
|
5
5
|
} from '@open-mercato/shared/lib/encryption/find'
|
|
6
|
+
import { Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
6
7
|
import {
|
|
7
8
|
AiChatConversation,
|
|
8
9
|
AiChatConversationParticipant,
|
|
@@ -29,8 +30,6 @@ import type {
|
|
|
29
30
|
* outside that boundary. The participant row is written transactionally
|
|
30
31
|
* alongside conversation create/import.
|
|
31
32
|
*
|
|
32
|
-
* TODO(ai-chat-sharing): widen the non-manage read predicate to include
|
|
33
|
-
* explicit undeleted participants once shared conversations are implemented.
|
|
34
33
|
*/
|
|
35
34
|
|
|
36
35
|
export interface AiChatConversationContext {
|
|
@@ -103,6 +102,27 @@ export class AiChatConversationAccessError extends Error {
|
|
|
103
102
|
}
|
|
104
103
|
}
|
|
105
104
|
|
|
105
|
+
export class AiChatConversationDuplicateParticipantError extends Error {
|
|
106
|
+
override readonly name = 'AiChatConversationDuplicateParticipantError'
|
|
107
|
+
constructor(message: string = 'User is already an active participant in this conversation.') {
|
|
108
|
+
super(message)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class AiChatParticipantNotFoundError extends Error {
|
|
113
|
+
override readonly name = 'AiChatParticipantNotFoundError'
|
|
114
|
+
constructor(message: string = 'Participant not found or already revoked.') {
|
|
115
|
+
super(message)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class AiChatConversationOrgNotFoundError extends Error {
|
|
120
|
+
override readonly name = 'AiChatConversationOrgNotFoundError'
|
|
121
|
+
constructor(message: string = 'Organization does not exist or is inactive for this tenant.') {
|
|
122
|
+
super(message)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
106
126
|
export class AiChatConversationRepository {
|
|
107
127
|
constructor(private readonly em: EntityManager) {}
|
|
108
128
|
|
|
@@ -135,6 +155,7 @@ export class AiChatConversationRepository {
|
|
|
135
155
|
}
|
|
136
156
|
return existing
|
|
137
157
|
}
|
|
158
|
+
await assertOrganizationExists(tx as unknown as EntityManager, ctx)
|
|
138
159
|
const conversation = tx.create(AiChatConversation, {
|
|
139
160
|
tenantId: ctx.tenantId,
|
|
140
161
|
organizationId: ctx.organizationId ?? null,
|
|
@@ -175,11 +196,21 @@ export class AiChatConversationRepository {
|
|
|
175
196
|
if (!conversationId) return null
|
|
176
197
|
const row = await findOneAccessibleConversation(this.em, conversationId, ctx)
|
|
177
198
|
if (!row) return null
|
|
178
|
-
|
|
199
|
+
const isParticipant =
|
|
200
|
+
!canManageConversations(ctx) && row.ownerUserId !== ctx.userId
|
|
201
|
+
? await this.loadParticipantFlag(
|
|
202
|
+
this.em,
|
|
203
|
+
ctx.tenantId!,
|
|
204
|
+
ctx.organizationId,
|
|
205
|
+
row.conversationId,
|
|
206
|
+
ctx.userId!,
|
|
207
|
+
)
|
|
208
|
+
: false
|
|
209
|
+
if (!canAccessConversation(row, ctx, isParticipant)) return null
|
|
179
210
|
return row
|
|
180
211
|
}
|
|
181
212
|
|
|
182
|
-
/** Owner-scoped list unless the caller has tenant/org manage access. */
|
|
213
|
+
/** Owner-scoped list unless the caller has tenant/org manage access. Participants also see shared conversations. */
|
|
183
214
|
async list(
|
|
184
215
|
ctx: AiChatConversationContext,
|
|
185
216
|
options: AiChatConversationListOptions = {},
|
|
@@ -191,7 +222,30 @@ export class AiChatConversationRepository {
|
|
|
191
222
|
organizationId: ctx.organizationId ?? null,
|
|
192
223
|
deletedAt: null,
|
|
193
224
|
}
|
|
194
|
-
if (!canManageConversations(ctx))
|
|
225
|
+
if (!canManageConversations(ctx)) {
|
|
226
|
+
const participantFilter: FilterQuery<AiChatConversationParticipant> = {
|
|
227
|
+
tenantId: ctx.tenantId,
|
|
228
|
+
userId: ctx.userId,
|
|
229
|
+
deletedAt: null,
|
|
230
|
+
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
231
|
+
}
|
|
232
|
+
const participantRows = await findWithDecryption<AiChatConversationParticipant>(
|
|
233
|
+
this.em,
|
|
234
|
+
AiChatConversationParticipant,
|
|
235
|
+
participantFilter,
|
|
236
|
+
{ fields: ['conversationId'] as any },
|
|
237
|
+
{ tenantId: ctx.tenantId ?? null, organizationId: ctx.organizationId ?? null },
|
|
238
|
+
)
|
|
239
|
+
const participantConvIds = participantRows.map((p) => p.conversationId)
|
|
240
|
+
if (participantConvIds.length > 0) {
|
|
241
|
+
where.$or = [
|
|
242
|
+
{ ownerUserId: ctx.userId },
|
|
243
|
+
{ conversationId: { $in: participantConvIds } },
|
|
244
|
+
]
|
|
245
|
+
} else {
|
|
246
|
+
where.ownerUserId = ctx.userId
|
|
247
|
+
}
|
|
248
|
+
}
|
|
195
249
|
if (options.agentId) where.agentId = options.agentId
|
|
196
250
|
if (options.status) where.status = options.status
|
|
197
251
|
if (options.cursor) {
|
|
@@ -517,6 +571,187 @@ export class AiChatConversationRepository {
|
|
|
517
571
|
skippedMessageCount: skipped,
|
|
518
572
|
}
|
|
519
573
|
}
|
|
574
|
+
|
|
575
|
+
async listParticipants(
|
|
576
|
+
conversationId: string,
|
|
577
|
+
ctx: AiChatConversationContext,
|
|
578
|
+
): Promise<AiChatConversationParticipant[]> {
|
|
579
|
+
assertContext(ctx, 'listParticipants')
|
|
580
|
+
const conv = await findOneAccessibleConversation(this.em, conversationId, ctx)
|
|
581
|
+
if (!conv) {
|
|
582
|
+
throw new AiChatConversationAccessError(
|
|
583
|
+
`Conversation "${conversationId}" was not found for the caller.`,
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
if (conv.ownerUserId !== ctx.userId && !canManageConversations(ctx)) {
|
|
587
|
+
throw new AiChatConversationAccessError(
|
|
588
|
+
'Only the conversation owner or a manager can list participants.',
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
const filter: FilterQuery<AiChatConversationParticipant> = {
|
|
592
|
+
tenantId: ctx.tenantId,
|
|
593
|
+
conversationId,
|
|
594
|
+
deletedAt: null,
|
|
595
|
+
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
596
|
+
}
|
|
597
|
+
return findWithDecryption<AiChatConversationParticipant>(
|
|
598
|
+
this.em,
|
|
599
|
+
AiChatConversationParticipant,
|
|
600
|
+
filter,
|
|
601
|
+
{ orderBy: { createdAt: 'asc' } as any },
|
|
602
|
+
{ tenantId: ctx.tenantId ?? null, organizationId: ctx.organizationId ?? null },
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async addParticipant(
|
|
607
|
+
conversationId: string,
|
|
608
|
+
userId: string,
|
|
609
|
+
role: 'viewer',
|
|
610
|
+
ctx: AiChatConversationContext,
|
|
611
|
+
): Promise<AiChatConversationParticipant> {
|
|
612
|
+
assertContext(ctx, 'addParticipant')
|
|
613
|
+
return this.em.transactional(async (tx) => {
|
|
614
|
+
const conv = await findOneAccessibleConversation(
|
|
615
|
+
tx as unknown as EntityManager,
|
|
616
|
+
conversationId,
|
|
617
|
+
ctx,
|
|
618
|
+
)
|
|
619
|
+
if (!conv) {
|
|
620
|
+
throw new AiChatConversationAccessError(
|
|
621
|
+
`Conversation "${conversationId}" was not found for the caller.`,
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
if (conv.ownerUserId !== ctx.userId) {
|
|
625
|
+
throw new AiChatConversationAccessError(
|
|
626
|
+
'Only the conversation owner can add participants.',
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
const existingFilter: FilterQuery<AiChatConversationParticipant> = {
|
|
630
|
+
tenantId: ctx.tenantId,
|
|
631
|
+
conversationId,
|
|
632
|
+
userId,
|
|
633
|
+
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
634
|
+
}
|
|
635
|
+
const existing = await findOneWithDecryption<AiChatConversationParticipant>(
|
|
636
|
+
tx as unknown as EntityManager,
|
|
637
|
+
AiChatConversationParticipant,
|
|
638
|
+
existingFilter,
|
|
639
|
+
)
|
|
640
|
+
if (existing) {
|
|
641
|
+
if (existing.deletedAt === null) {
|
|
642
|
+
throw new AiChatConversationDuplicateParticipantError()
|
|
643
|
+
}
|
|
644
|
+
existing.deletedAt = null
|
|
645
|
+
existing.role = role
|
|
646
|
+
await tx.persist(existing).flush()
|
|
647
|
+
if (conv.visibility === 'private') {
|
|
648
|
+
conv.visibility = 'shared'
|
|
649
|
+
await tx.persist(conv).flush()
|
|
650
|
+
}
|
|
651
|
+
return existing
|
|
652
|
+
}
|
|
653
|
+
const participant = tx.create(AiChatConversationParticipant, {
|
|
654
|
+
tenantId: ctx.tenantId!,
|
|
655
|
+
organizationId: ctx.organizationId ?? null,
|
|
656
|
+
conversationId,
|
|
657
|
+
userId,
|
|
658
|
+
role,
|
|
659
|
+
} as unknown as AiChatConversationParticipant)
|
|
660
|
+
if (conv.visibility === 'private') {
|
|
661
|
+
conv.visibility = 'shared'
|
|
662
|
+
}
|
|
663
|
+
await tx.persist(participant).persist(conv).flush()
|
|
664
|
+
return participant
|
|
665
|
+
})
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async revokeParticipant(
|
|
669
|
+
conversationId: string,
|
|
670
|
+
targetUserId: string,
|
|
671
|
+
ctx: AiChatConversationContext,
|
|
672
|
+
): Promise<void> {
|
|
673
|
+
assertContext(ctx, 'revokeParticipant')
|
|
674
|
+
await this.em.transactional(async (tx) => {
|
|
675
|
+
const conv = await findOneAccessibleConversation(
|
|
676
|
+
tx as unknown as EntityManager,
|
|
677
|
+
conversationId,
|
|
678
|
+
ctx,
|
|
679
|
+
)
|
|
680
|
+
if (!conv) {
|
|
681
|
+
throw new AiChatConversationAccessError(
|
|
682
|
+
`Conversation "${conversationId}" was not found for the caller.`,
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
if (conv.ownerUserId !== ctx.userId) {
|
|
686
|
+
throw new AiChatConversationAccessError(
|
|
687
|
+
'Only the conversation owner can revoke participants.',
|
|
688
|
+
)
|
|
689
|
+
}
|
|
690
|
+
if (targetUserId === conv.ownerUserId) {
|
|
691
|
+
throw new AiChatConversationAccessError('Cannot revoke the conversation owner.')
|
|
692
|
+
}
|
|
693
|
+
const participantFilter: FilterQuery<AiChatConversationParticipant> = {
|
|
694
|
+
tenantId: ctx.tenantId,
|
|
695
|
+
conversationId,
|
|
696
|
+
userId: targetUserId,
|
|
697
|
+
deletedAt: null,
|
|
698
|
+
...(ctx.organizationId ? { organizationId: ctx.organizationId } : {}),
|
|
699
|
+
}
|
|
700
|
+
const participant = await findOneWithDecryption<AiChatConversationParticipant>(
|
|
701
|
+
tx as unknown as EntityManager,
|
|
702
|
+
AiChatConversationParticipant,
|
|
703
|
+
participantFilter,
|
|
704
|
+
)
|
|
705
|
+
if (!participant) throw new AiChatParticipantNotFoundError()
|
|
706
|
+
participant.deletedAt = new Date()
|
|
707
|
+
const remainingCount = await tx.count(AiChatConversationParticipant, {
|
|
708
|
+
tenantId: ctx.tenantId,
|
|
709
|
+
conversationId,
|
|
710
|
+
deletedAt: null,
|
|
711
|
+
role: { $ne: 'owner' },
|
|
712
|
+
} as FilterQuery<AiChatConversationParticipant>)
|
|
713
|
+
if (remainingCount <= 1) {
|
|
714
|
+
conv.visibility = 'private'
|
|
715
|
+
await tx.persist(conv)
|
|
716
|
+
}
|
|
717
|
+
await tx.persist(participant).flush()
|
|
718
|
+
})
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async getParticipantCount(
|
|
722
|
+
tenantId: string,
|
|
723
|
+
organizationId: string | null | undefined,
|
|
724
|
+
conversationId: string,
|
|
725
|
+
): Promise<number> {
|
|
726
|
+
return this.em.count(AiChatConversationParticipant, {
|
|
727
|
+
tenantId,
|
|
728
|
+
conversationId,
|
|
729
|
+
deletedAt: null,
|
|
730
|
+
role: { $ne: 'owner' },
|
|
731
|
+
...(organizationId ? { organizationId } : {}),
|
|
732
|
+
} as FilterQuery<AiChatConversationParticipant>)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private async loadParticipantFlag(
|
|
736
|
+
em: EntityManager,
|
|
737
|
+
tenantId: string,
|
|
738
|
+
organizationId: string | null | undefined,
|
|
739
|
+
conversationId: string,
|
|
740
|
+
userId: string,
|
|
741
|
+
): Promise<boolean> {
|
|
742
|
+
const row = await findOneWithDecryption<AiChatConversationParticipant>(
|
|
743
|
+
em,
|
|
744
|
+
AiChatConversationParticipant,
|
|
745
|
+
{
|
|
746
|
+
tenantId,
|
|
747
|
+
conversationId,
|
|
748
|
+
userId,
|
|
749
|
+
deletedAt: null,
|
|
750
|
+
...(organizationId ? { organizationId } : {}),
|
|
751
|
+
} as FilterQuery<AiChatConversationParticipant>,
|
|
752
|
+
)
|
|
753
|
+
return row !== null
|
|
754
|
+
}
|
|
520
755
|
}
|
|
521
756
|
|
|
522
757
|
function assertContext(ctx: AiChatConversationContext | undefined, method: string): void {
|
|
@@ -535,8 +770,36 @@ function canManageConversations(ctx: AiChatConversationContext): boolean {
|
|
|
535
770
|
function canAccessConversation(
|
|
536
771
|
row: AiChatConversation,
|
|
537
772
|
ctx: AiChatConversationContext,
|
|
773
|
+
isParticipant = false,
|
|
538
774
|
): boolean {
|
|
539
|
-
return canManageConversations(ctx) || row.ownerUserId === ctx.userId
|
|
775
|
+
return canManageConversations(ctx) || row.ownerUserId === ctx.userId || isParticipant
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function assertOrganizationExists(
|
|
779
|
+
em: EntityManager,
|
|
780
|
+
ctx: AiChatConversationContext,
|
|
781
|
+
): Promise<void> {
|
|
782
|
+
if (!ctx.organizationId) return
|
|
783
|
+
const org = await findOneWithDecryption<Organization>(
|
|
784
|
+
em,
|
|
785
|
+
Organization,
|
|
786
|
+
{
|
|
787
|
+
id: ctx.organizationId,
|
|
788
|
+
tenant: ctx.tenantId,
|
|
789
|
+
deletedAt: null,
|
|
790
|
+
isActive: true,
|
|
791
|
+
} as any,
|
|
792
|
+
{},
|
|
793
|
+
{
|
|
794
|
+
tenantId: ctx.tenantId ?? null,
|
|
795
|
+
organizationId: ctx.organizationId ?? null,
|
|
796
|
+
},
|
|
797
|
+
)
|
|
798
|
+
if (!org) {
|
|
799
|
+
throw new AiChatConversationOrgNotFoundError(
|
|
800
|
+
`Organization "${ctx.organizationId}" does not exist or is inactive in tenant "${ctx.tenantId}".`,
|
|
801
|
+
)
|
|
802
|
+
}
|
|
540
803
|
}
|
|
541
804
|
|
|
542
805
|
async function findOneAccessibleConversation(
|
package/src/modules/ai_assistant/data/repositories/__tests__/AiChatConversationRepository.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
1
2
|
import {
|
|
2
3
|
AiChatConversation,
|
|
3
4
|
AiChatConversationParticipant,
|
|
@@ -5,6 +6,9 @@ import {
|
|
|
5
6
|
} from '../../entities'
|
|
6
7
|
import {
|
|
7
8
|
AiChatConversationAccessError,
|
|
9
|
+
AiChatConversationDuplicateParticipantError,
|
|
10
|
+
AiChatConversationOrgNotFoundError,
|
|
11
|
+
AiChatParticipantNotFoundError,
|
|
8
12
|
AiChatConversationRepository,
|
|
9
13
|
} from '../AiChatConversationRepository'
|
|
10
14
|
|
|
@@ -36,6 +40,7 @@ type ParticipantRow = {
|
|
|
36
40
|
lastReadAt: Date | null
|
|
37
41
|
createdAt: Date
|
|
38
42
|
updatedAt: Date
|
|
43
|
+
deletedAt: Date | null
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
type MessageRow = {
|
|
@@ -62,6 +67,11 @@ let idCounter = 0
|
|
|
62
67
|
function matchesWhere(row: Record<string, any>, where: any): boolean {
|
|
63
68
|
if (!where) return true
|
|
64
69
|
for (const key of Object.keys(where)) {
|
|
70
|
+
if (key === '$or') {
|
|
71
|
+
const conditions = where[key] as any[]
|
|
72
|
+
if (!conditions.some((cond) => matchesWhere(row, cond))) return false
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
65
75
|
const expected = where[key]
|
|
66
76
|
const actual = row[key] ?? null
|
|
67
77
|
if (expected && typeof expected === 'object' && '$lt' in expected) {
|
|
@@ -69,6 +79,15 @@ function matchesWhere(row: Record<string, any>, where: any): boolean {
|
|
|
69
79
|
if (!(actual instanceof Date) || !(actual.getTime() < lt.getTime())) return false
|
|
70
80
|
continue
|
|
71
81
|
}
|
|
82
|
+
if (expected && typeof expected === 'object' && '$in' in expected) {
|
|
83
|
+
const inList = expected.$in as unknown[]
|
|
84
|
+
if (!inList.includes(actual)) return false
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (expected && typeof expected === 'object' && '$ne' in expected) {
|
|
88
|
+
if (actual === expected.$ne) return false
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
72
91
|
if (expected === null) {
|
|
73
92
|
if (actual !== null && actual !== undefined) return false
|
|
74
93
|
continue
|
|
@@ -99,18 +118,29 @@ function applyOrder<T extends Record<string, any>>(rows: T[], orderBy: any): T[]
|
|
|
99
118
|
})
|
|
100
119
|
}
|
|
101
120
|
|
|
102
|
-
|
|
121
|
+
type OrgRow = {
|
|
122
|
+
id: string
|
|
123
|
+
// ManyToOne(() => Tenant) — the production filter uses `tenant: tenantId`,
|
|
124
|
+
// not `tenantId`, so the mock row mirrors that shape.
|
|
125
|
+
tenant: string
|
|
126
|
+
isActive: boolean
|
|
127
|
+
deletedAt: Date | null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function entityKey(entity: unknown): 'conv' | 'participant' | 'message' | 'org' | null {
|
|
103
131
|
if (entity === AiChatConversation) return 'conv'
|
|
104
132
|
if (entity === AiChatConversationParticipant) return 'participant'
|
|
105
133
|
if (entity === AiChatMessage) return 'message'
|
|
134
|
+
if (entity === Organization) return 'org'
|
|
106
135
|
return null
|
|
107
136
|
}
|
|
108
137
|
|
|
109
|
-
function mockEm() {
|
|
110
|
-
const stores: Record<'conv' | 'participant' | 'message', any[]> = {
|
|
138
|
+
function mockEm(options: { orgs?: OrgRow[] } = {}) {
|
|
139
|
+
const stores: Record<'conv' | 'participant' | 'message' | 'org', any[]> = {
|
|
111
140
|
conv: [],
|
|
112
141
|
participant: [],
|
|
113
142
|
message: [],
|
|
143
|
+
org: options.orgs ?? [],
|
|
114
144
|
}
|
|
115
145
|
|
|
116
146
|
const find = async (entity: unknown, where: any, options?: any): Promise<any[]> => {
|
|
@@ -130,6 +160,10 @@ function mockEm() {
|
|
|
130
160
|
const rows = await find(entity, where, options)
|
|
131
161
|
return rows[0] ?? null
|
|
132
162
|
},
|
|
163
|
+
count: async (entity: unknown, where: any) => {
|
|
164
|
+
const rows = await find(entity, where)
|
|
165
|
+
return rows.length
|
|
166
|
+
},
|
|
133
167
|
create: (entity: unknown, data: any) => {
|
|
134
168
|
idCounter += 1
|
|
135
169
|
const key = entityKey(entity)
|
|
@@ -165,6 +199,7 @@ function mockEm() {
|
|
|
165
199
|
lastReadAt: data.lastReadAt ?? null,
|
|
166
200
|
createdAt: data.createdAt instanceof Date ? data.createdAt : new Date(),
|
|
167
201
|
updatedAt: data.updatedAt instanceof Date ? data.updatedAt : new Date(),
|
|
202
|
+
deletedAt: data.deletedAt ?? null,
|
|
168
203
|
}
|
|
169
204
|
return row
|
|
170
205
|
}
|
|
@@ -589,4 +624,263 @@ describe('AiChatConversationRepository', () => {
|
|
|
589
624
|
em.__stores.conv.find((row: ConvRow) => row.conversationId === 'other-tenant')?.deletedAt,
|
|
590
625
|
).toBeNull()
|
|
591
626
|
})
|
|
627
|
+
|
|
628
|
+
it('addParticipant rejects an active duplicate with AiChatConversationDuplicateParticipantError (BUG-001)', async () => {
|
|
629
|
+
const em = mockEm()
|
|
630
|
+
const repo = new AiChatConversationRepository(em)
|
|
631
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
632
|
+
await repo.createOrGet({ conversationId: 'c-dup', agentId: 'a' }, ownerCtx)
|
|
633
|
+
await repo.addParticipant('c-dup', 'u-viewer', 'viewer', ownerCtx)
|
|
634
|
+
|
|
635
|
+
await expect(
|
|
636
|
+
repo.addParticipant('c-dup', 'u-viewer', 'viewer', ownerCtx),
|
|
637
|
+
).rejects.toBeInstanceOf(AiChatConversationDuplicateParticipantError)
|
|
638
|
+
|
|
639
|
+
const active = em.__stores.participant.filter(
|
|
640
|
+
(row: ParticipantRow) => row.userId === 'u-viewer',
|
|
641
|
+
)
|
|
642
|
+
expect(active).toHaveLength(1)
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('addParticipant restores a previously revoked participant without duplicating the row (BUG-001 boundary)', async () => {
|
|
646
|
+
const em = mockEm()
|
|
647
|
+
const repo = new AiChatConversationRepository(em)
|
|
648
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
649
|
+
await repo.createOrGet({ conversationId: 'c-restore', agentId: 'a' }, ownerCtx)
|
|
650
|
+
await repo.addParticipant('c-restore', 'u-viewer', 'viewer', ownerCtx)
|
|
651
|
+
await repo.revokeParticipant('c-restore', 'u-viewer', ownerCtx)
|
|
652
|
+
|
|
653
|
+
const restored = await repo.addParticipant('c-restore', 'u-viewer', 'viewer', ownerCtx)
|
|
654
|
+
expect(restored.userId).toBe('u-viewer')
|
|
655
|
+
const allRows = em.__stores.participant.filter(
|
|
656
|
+
(row: ParticipantRow) => row.userId === 'u-viewer',
|
|
657
|
+
)
|
|
658
|
+
expect(allRows).toHaveLength(1)
|
|
659
|
+
expect((allRows[0] as any).deletedAt).toBeNull()
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('addParticipant refuses a non-owner caller even when canManageConversations=true (BUG-002)', async () => {
|
|
663
|
+
const em = mockEm()
|
|
664
|
+
const repo = new AiChatConversationRepository(em)
|
|
665
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
666
|
+
await repo.createOrGet({ conversationId: 'c-bug-002', agentId: 'a' }, ownerCtx)
|
|
667
|
+
|
|
668
|
+
const managerCtx = {
|
|
669
|
+
tenantId: tenantAlpha,
|
|
670
|
+
organizationId: null,
|
|
671
|
+
userId: 'u-manager',
|
|
672
|
+
canManageConversations: true,
|
|
673
|
+
}
|
|
674
|
+
await expect(
|
|
675
|
+
repo.addParticipant('c-bug-002', 'u-victim', 'viewer', managerCtx),
|
|
676
|
+
).rejects.toBeInstanceOf(AiChatConversationAccessError)
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('revokeParticipant refuses a non-owner caller even when canManageConversations=true (BUG-002)', async () => {
|
|
680
|
+
const em = mockEm()
|
|
681
|
+
const repo = new AiChatConversationRepository(em)
|
|
682
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
683
|
+
await repo.createOrGet({ conversationId: 'c-bug-002-rev', agentId: 'a' }, ownerCtx)
|
|
684
|
+
await repo.addParticipant('c-bug-002-rev', 'u-viewer', 'viewer', ownerCtx)
|
|
685
|
+
|
|
686
|
+
const managerCtx = {
|
|
687
|
+
tenantId: tenantAlpha,
|
|
688
|
+
organizationId: null,
|
|
689
|
+
userId: 'u-manager',
|
|
690
|
+
canManageConversations: true,
|
|
691
|
+
}
|
|
692
|
+
await expect(
|
|
693
|
+
repo.revokeParticipant('c-bug-002-rev', 'u-viewer', managerCtx),
|
|
694
|
+
).rejects.toBeInstanceOf(AiChatConversationAccessError)
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('revokeParticipant blocks revoking the conversation owner (BUG-002)', async () => {
|
|
698
|
+
const em = mockEm()
|
|
699
|
+
const repo = new AiChatConversationRepository(em)
|
|
700
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
701
|
+
await repo.createOrGet({ conversationId: 'c-no-self-revoke', agentId: 'a' }, ownerCtx)
|
|
702
|
+
|
|
703
|
+
await expect(
|
|
704
|
+
repo.revokeParticipant('c-no-self-revoke', 'u-owner', ownerCtx),
|
|
705
|
+
).rejects.toBeInstanceOf(AiChatConversationAccessError)
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
it('listParticipants throws AccessError for a non-owner / non-manager caller (BUG-006)', async () => {
|
|
709
|
+
const em = mockEm()
|
|
710
|
+
const repo = new AiChatConversationRepository(em)
|
|
711
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
712
|
+
await repo.createOrGet({ conversationId: 'c-bug-006', agentId: 'a' }, ownerCtx)
|
|
713
|
+
await repo.addParticipant('c-bug-006', 'u-viewer', 'viewer', ownerCtx)
|
|
714
|
+
|
|
715
|
+
const viewerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-viewer' }
|
|
716
|
+
await expect(repo.listParticipants('c-bug-006', viewerCtx)).rejects.toBeInstanceOf(
|
|
717
|
+
AiChatConversationAccessError,
|
|
718
|
+
)
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
it('listParticipants returns the active participants for the owner', async () => {
|
|
722
|
+
const em = mockEm()
|
|
723
|
+
const repo = new AiChatConversationRepository(em)
|
|
724
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
725
|
+
await repo.createOrGet({ conversationId: 'c-list-ok', agentId: 'a' }, ownerCtx)
|
|
726
|
+
await repo.addParticipant('c-list-ok', 'u-viewer-1', 'viewer', ownerCtx)
|
|
727
|
+
await repo.addParticipant('c-list-ok', 'u-viewer-2', 'viewer', ownerCtx)
|
|
728
|
+
|
|
729
|
+
const list = await repo.listParticipants('c-list-ok', ownerCtx)
|
|
730
|
+
expect(list.map((p) => p.userId).sort()).toEqual(
|
|
731
|
+
['u-owner', 'u-viewer-1', 'u-viewer-2'].sort(),
|
|
732
|
+
)
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
it('listParticipants allows a conversation manager to enumerate participants (BUG-006 manager exception)', async () => {
|
|
736
|
+
const em = mockEm()
|
|
737
|
+
const repo = new AiChatConversationRepository(em)
|
|
738
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
739
|
+
await repo.createOrGet({ conversationId: 'c-list-mgr', agentId: 'a' }, ownerCtx)
|
|
740
|
+
await repo.addParticipant('c-list-mgr', 'u-viewer', 'viewer', ownerCtx)
|
|
741
|
+
|
|
742
|
+
const managerCtx = {
|
|
743
|
+
tenantId: tenantAlpha,
|
|
744
|
+
organizationId: null,
|
|
745
|
+
userId: 'u-manager',
|
|
746
|
+
canManageConversations: true,
|
|
747
|
+
}
|
|
748
|
+
const list = await repo.listParticipants('c-list-mgr', managerCtx)
|
|
749
|
+
expect(list.find((p) => p.userId === 'u-viewer')).toBeDefined()
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
it('createOrGet rejects an orphan organizationId with AiChatConversationOrgNotFoundError (BUG-005)', async () => {
|
|
753
|
+
const em = mockEm({
|
|
754
|
+
orgs: [{ id: 'org-real', tenant: tenantAlpha, isActive: true, deletedAt: null }],
|
|
755
|
+
})
|
|
756
|
+
const repo = new AiChatConversationRepository(em)
|
|
757
|
+
const ctx = {
|
|
758
|
+
tenantId: tenantAlpha,
|
|
759
|
+
organizationId: 'org-orphan',
|
|
760
|
+
userId: 'u-owner',
|
|
761
|
+
}
|
|
762
|
+
await expect(
|
|
763
|
+
repo.createOrGet({ conversationId: 'c-orphan', agentId: 'a' }, ctx),
|
|
764
|
+
).rejects.toBeInstanceOf(AiChatConversationOrgNotFoundError)
|
|
765
|
+
expect(em.__stores.conv).toHaveLength(0)
|
|
766
|
+
expect(em.__stores.participant).toHaveLength(0)
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it('createOrGet rejects an inactive organization with AiChatConversationOrgNotFoundError (BUG-005)', async () => {
|
|
770
|
+
const em = mockEm({
|
|
771
|
+
orgs: [{ id: 'org-disabled', tenant: tenantAlpha, isActive: false, deletedAt: null }],
|
|
772
|
+
})
|
|
773
|
+
const repo = new AiChatConversationRepository(em)
|
|
774
|
+
const ctx = {
|
|
775
|
+
tenantId: tenantAlpha,
|
|
776
|
+
organizationId: 'org-disabled',
|
|
777
|
+
userId: 'u-owner',
|
|
778
|
+
}
|
|
779
|
+
await expect(
|
|
780
|
+
repo.createOrGet({ conversationId: 'c-inactive', agentId: 'a' }, ctx),
|
|
781
|
+
).rejects.toBeInstanceOf(AiChatConversationOrgNotFoundError)
|
|
782
|
+
expect(em.__stores.conv).toHaveLength(0)
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
it('createOrGet rejects a soft-deleted organization with AiChatConversationOrgNotFoundError (BUG-005)', async () => {
|
|
786
|
+
const em = mockEm({
|
|
787
|
+
orgs: [
|
|
788
|
+
{ id: 'org-deleted', tenant: tenantAlpha, isActive: true, deletedAt: new Date('2026-01-01') },
|
|
789
|
+
],
|
|
790
|
+
})
|
|
791
|
+
const repo = new AiChatConversationRepository(em)
|
|
792
|
+
const ctx = {
|
|
793
|
+
tenantId: tenantAlpha,
|
|
794
|
+
organizationId: 'org-deleted',
|
|
795
|
+
userId: 'u-owner',
|
|
796
|
+
}
|
|
797
|
+
await expect(
|
|
798
|
+
repo.createOrGet({ conversationId: 'c-deleted', agentId: 'a' }, ctx),
|
|
799
|
+
).rejects.toBeInstanceOf(AiChatConversationOrgNotFoundError)
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it('createOrGet rejects a cross-tenant organizationId (BUG-005 tenant scope)', async () => {
|
|
803
|
+
const em = mockEm({
|
|
804
|
+
orgs: [{ id: 'org-other-tenant', tenant: tenantBeta, isActive: true, deletedAt: null }],
|
|
805
|
+
})
|
|
806
|
+
const repo = new AiChatConversationRepository(em)
|
|
807
|
+
const ctx = {
|
|
808
|
+
tenantId: tenantAlpha,
|
|
809
|
+
organizationId: 'org-other-tenant',
|
|
810
|
+
userId: 'u-owner',
|
|
811
|
+
}
|
|
812
|
+
await expect(
|
|
813
|
+
repo.createOrGet({ conversationId: 'c-cross-tenant', agentId: 'a' }, ctx),
|
|
814
|
+
).rejects.toBeInstanceOf(AiChatConversationOrgNotFoundError)
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('createOrGet persists when organizationId references a live, active org (BUG-005 happy path)', async () => {
|
|
818
|
+
const em = mockEm({
|
|
819
|
+
orgs: [{ id: 'org-live', tenant: tenantAlpha, isActive: true, deletedAt: null }],
|
|
820
|
+
})
|
|
821
|
+
const repo = new AiChatConversationRepository(em)
|
|
822
|
+
const ctx = {
|
|
823
|
+
tenantId: tenantAlpha,
|
|
824
|
+
organizationId: 'org-live',
|
|
825
|
+
userId: 'u-owner',
|
|
826
|
+
}
|
|
827
|
+
const row = await repo.createOrGet({ conversationId: 'c-ok', agentId: 'a' }, ctx)
|
|
828
|
+
expect(row.conversationId).toBe('c-ok')
|
|
829
|
+
expect(row.organizationId).toBe('org-live')
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
it('createOrGet persists when organizationId is null (no org selected — additive null path)', async () => {
|
|
833
|
+
const em = mockEm()
|
|
834
|
+
const repo = new AiChatConversationRepository(em)
|
|
835
|
+
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
836
|
+
const row = await repo.createOrGet({ conversationId: 'c-null-org', agentId: 'a' }, ctx)
|
|
837
|
+
expect(row.organizationId).toBeNull()
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
it('getParticipantCount excludes the owner and counts only non-owner active participants', async () => {
|
|
841
|
+
const em = mockEm()
|
|
842
|
+
const repo = new AiChatConversationRepository(em)
|
|
843
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
844
|
+
await repo.createOrGet({ conversationId: 'c-count', agentId: 'a' }, ownerCtx)
|
|
845
|
+
await repo.addParticipant('c-count', 'u-v1', 'viewer', ownerCtx)
|
|
846
|
+
await repo.addParticipant('c-count', 'u-v2', 'viewer', ownerCtx)
|
|
847
|
+
await repo.revokeParticipant('c-count', 'u-v1', ownerCtx)
|
|
848
|
+
|
|
849
|
+
const total = await repo.getParticipantCount(tenantAlpha, null, 'c-count')
|
|
850
|
+
expect(total).toBe(1)
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
it('getParticipantCount returns 0 for a private (owner-only) conversation', async () => {
|
|
854
|
+
const em = mockEm()
|
|
855
|
+
const repo = new AiChatConversationRepository(em)
|
|
856
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
857
|
+
await repo.createOrGet({ conversationId: 'c-private', agentId: 'a' }, ownerCtx)
|
|
858
|
+
|
|
859
|
+
const count = await repo.getParticipantCount(tenantAlpha, null, 'c-private')
|
|
860
|
+
expect(count).toBe(0)
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
it('revokeParticipant throws AiChatParticipantNotFoundError for a non-existent userId', async () => {
|
|
864
|
+
const em = mockEm()
|
|
865
|
+
const repo = new AiChatConversationRepository(em)
|
|
866
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
867
|
+
await repo.createOrGet({ conversationId: 'c-revoke-nf', agentId: 'a' }, ownerCtx)
|
|
868
|
+
|
|
869
|
+
await expect(
|
|
870
|
+
repo.revokeParticipant('c-revoke-nf', 'u-nonexistent', ownerCtx),
|
|
871
|
+
).rejects.toBeInstanceOf(AiChatParticipantNotFoundError)
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
it('revokeParticipant throws AiChatParticipantNotFoundError when revoking an already-revoked participant', async () => {
|
|
875
|
+
const em = mockEm()
|
|
876
|
+
const repo = new AiChatConversationRepository(em)
|
|
877
|
+
const ownerCtx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-owner' }
|
|
878
|
+
await repo.createOrGet({ conversationId: 'c-double-revoke', agentId: 'a' }, ownerCtx)
|
|
879
|
+
await repo.addParticipant('c-double-revoke', 'u-viewer', 'viewer', ownerCtx)
|
|
880
|
+
await repo.revokeParticipant('c-double-revoke', 'u-viewer', ownerCtx)
|
|
881
|
+
|
|
882
|
+
await expect(
|
|
883
|
+
repo.revokeParticipant('c-double-revoke', 'u-viewer', ownerCtx),
|
|
884
|
+
).rejects.toBeInstanceOf(AiChatParticipantNotFoundError)
|
|
885
|
+
})
|
|
592
886
|
})
|