@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.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.js +87 -0
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.js.map +7 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.js +119 -0
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.js.map +7 -0
  6. package/dist/modules/ai_assistant/acl.js +1 -0
  7. package/dist/modules/ai_assistant/acl.js.map +2 -2
  8. package/dist/modules/ai_assistant/api/ai/chat/route.js +3 -0
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  10. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.js +128 -0
  11. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.js.map +7 -0
  12. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.js +271 -0
  13. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.js.map +7 -0
  14. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js +9 -1
  15. package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js.map +2 -2
  16. package/dist/modules/ai_assistant/api/ai/conversations/route.js +4 -1
  17. package/dist/modules/ai_assistant/api/ai/conversations/route.js.map +2 -2
  18. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +5 -1
  19. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  20. package/dist/modules/ai_assistant/components/ConversationShareButton.js +5 -0
  21. package/dist/modules/ai_assistant/components/ConversationShareButton.js.map +7 -0
  22. package/dist/modules/ai_assistant/components/ConversationShareDialog.js +5 -0
  23. package/dist/modules/ai_assistant/components/ConversationShareDialog.js.map +7 -0
  24. package/dist/modules/ai_assistant/data/entities.js +3 -0
  25. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  26. package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js +235 -5
  27. package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js.map +2 -2
  28. package/dist/modules/ai_assistant/events.js +14 -0
  29. package/dist/modules/ai_assistant/events.js.map +2 -2
  30. package/dist/modules/ai_assistant/i18n/de.json +17 -0
  31. package/dist/modules/ai_assistant/i18n/en.json +17 -0
  32. package/dist/modules/ai_assistant/i18n/es.json +17 -0
  33. package/dist/modules/ai_assistant/i18n/pl.json +17 -0
  34. package/dist/modules/ai_assistant/lib/conversation-storage.js +12 -3
  35. package/dist/modules/ai_assistant/lib/conversation-storage.js.map +2 -2
  36. package/dist/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.js +15 -0
  37. package/dist/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.js.map +7 -0
  38. package/dist/modules/ai_assistant/notifications.client.js +30 -0
  39. package/dist/modules/ai_assistant/notifications.client.js.map +7 -0
  40. package/dist/modules/ai_assistant/notifications.js +27 -0
  41. package/dist/modules/ai_assistant/notifications.js.map +7 -0
  42. package/dist/modules/ai_assistant/setup.js +2 -1
  43. package/dist/modules/ai_assistant/setup.js.map +2 -2
  44. package/dist/modules/ai_assistant/subscribers/conversation-shared-notify.js +59 -0
  45. package/dist/modules/ai_assistant/subscribers/conversation-shared-notify.js.map +7 -0
  46. package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js +123 -0
  47. package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js.map +7 -0
  48. package/generated/entities/ai_chat_conversation_participant/index.ts +1 -0
  49. package/generated/entity-fields-registry.ts +1 -0
  50. package/package.json +7 -8
  51. package/src/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.ts +117 -0
  52. package/src/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.ts +159 -0
  53. package/src/modules/ai_assistant/__tests__/integration/ai-chat-sharing.test.ts +406 -0
  54. package/src/modules/ai_assistant/acl.ts +1 -0
  55. package/src/modules/ai_assistant/api/ai/chat/route.ts +3 -0
  56. package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.ts +149 -0
  57. package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.ts +314 -0
  58. package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/route.ts +9 -1
  59. package/src/modules/ai_assistant/api/ai/conversations/route.ts +4 -1
  60. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -0
  61. package/src/modules/ai_assistant/components/ConversationShareButton.tsx +1 -0
  62. package/src/modules/ai_assistant/components/ConversationShareDialog.tsx +1 -0
  63. package/src/modules/ai_assistant/data/entities.ts +4 -0
  64. package/src/modules/ai_assistant/data/repositories/AiChatConversationRepository.ts +270 -7
  65. package/src/modules/ai_assistant/data/repositories/__tests__/AiChatConversationRepository.test.ts +297 -3
  66. package/src/modules/ai_assistant/events.ts +31 -0
  67. package/src/modules/ai_assistant/i18n/__tests__/conversation-share-translations.test.ts +59 -0
  68. package/src/modules/ai_assistant/i18n/de.json +17 -0
  69. package/src/modules/ai_assistant/i18n/en.json +17 -0
  70. package/src/modules/ai_assistant/i18n/es.json +17 -0
  71. package/src/modules/ai_assistant/i18n/pl.json +17 -0
  72. package/src/modules/ai_assistant/lib/conversation-storage.ts +22 -1
  73. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +25 -0
  74. package/src/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.ts +15 -0
  75. package/src/modules/ai_assistant/notifications.client.ts +29 -0
  76. package/src/modules/ai_assistant/notifications.ts +25 -0
  77. package/src/modules/ai_assistant/setup.ts +2 -1
  78. package/src/modules/ai_assistant/subscribers/__tests__/conversation-shared-notify.test.ts +116 -0
  79. package/src/modules/ai_assistant/subscribers/conversation-shared-notify.ts +78 -0
  80. 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
- if (!canAccessConversation(row, ctx)) return null
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)) where.ownerUserId = ctx.userId
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(
@@ -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
- function entityKey(entity: unknown): 'conv' | 'participant' | 'message' | null {
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
  })