@things-factory/ai-assistant 10.1.207 → 10.1.210

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 (181) hide show
  1. package/client/components/assistant-chat.ts +238 -9
  2. package/client/components/assistant-dock.ts +9 -4
  3. package/client/components/assistant-invite.ts +138 -0
  4. package/client/components/assistant-session-manager.ts +53 -12
  5. package/client/components/proposal-request.test.ts +10 -0
  6. package/client/components/roster-identity.test.ts +2 -12
  7. package/client/components/roster-identity.ts +0 -6
  8. package/client/setup-assistant-dock.ts +1 -0
  9. package/client/utils/assistant-session-actions.ts +3 -1
  10. package/client/utils/assistant-session-admin.ts +9 -2
  11. package/client/utils/assistant-session-controller.ts +1 -1
  12. package/client/utils/assistant-typing.test.ts +33 -0
  13. package/client/utils/assistant-typing.ts +53 -0
  14. package/dist-client/components/assistant-chat.d.ts +28 -0
  15. package/dist-client/components/assistant-chat.js +259 -8
  16. package/dist-client/components/assistant-chat.js.map +1 -1
  17. package/dist-client/components/assistant-dock.js +12 -4
  18. package/dist-client/components/assistant-dock.js.map +1 -1
  19. package/dist-client/components/assistant-invite.d.ts +39 -0
  20. package/dist-client/components/assistant-invite.js +150 -0
  21. package/dist-client/components/assistant-invite.js.map +1 -0
  22. package/dist-client/components/assistant-session-manager.d.ts +5 -1
  23. package/dist-client/components/assistant-session-manager.js +40 -10
  24. package/dist-client/components/assistant-session-manager.js.map +1 -1
  25. package/dist-client/components/proposal-request.test.js +8 -0
  26. package/dist-client/components/proposal-request.test.js.map +1 -1
  27. package/dist-client/components/roster-identity.d.ts +0 -2
  28. package/dist-client/components/roster-identity.js +0 -7
  29. package/dist-client/components/roster-identity.js.map +1 -1
  30. package/dist-client/components/roster-identity.test.js +2 -13
  31. package/dist-client/components/roster-identity.test.js.map +1 -1
  32. package/dist-client/setup-assistant-dock.js +1 -0
  33. package/dist-client/setup-assistant-dock.js.map +1 -1
  34. package/dist-client/tsconfig.tsbuildinfo +1 -1
  35. package/dist-client/utils/assistant-session-actions.d.ts +1 -0
  36. package/dist-client/utils/assistant-session-actions.js +3 -1
  37. package/dist-client/utils/assistant-session-actions.js.map +1 -1
  38. package/dist-client/utils/assistant-session-admin.d.ts +5 -2
  39. package/dist-client/utils/assistant-session-admin.js +3 -0
  40. package/dist-client/utils/assistant-session-admin.js.map +1 -1
  41. package/dist-client/utils/assistant-session-controller.d.ts +1 -1
  42. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  43. package/dist-client/utils/assistant-typing.d.ts +20 -0
  44. package/dist-client/utils/assistant-typing.js +48 -0
  45. package/dist-client/utils/assistant-typing.js.map +1 -0
  46. package/dist-client/utils/assistant-typing.test.d.ts +1 -0
  47. package/dist-client/utils/assistant-typing.test.js +30 -0
  48. package/dist-client/utils/assistant-typing.test.js.map +1 -0
  49. package/dist-server/service/assistant-chat-resolver.js +5 -12
  50. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  51. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  52. package/dist-server/service/assistant-mode/index.js +4 -1
  53. package/dist-server/service/assistant-mode/index.js.map +1 -1
  54. package/dist-server/service/assistant-mode/mode-categories.js +9 -2
  55. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  56. package/dist-server/service/assistant-mode/screen-rules.d.ts +19 -0
  57. package/dist-server/service/assistant-mode/screen-rules.js +40 -6
  58. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  59. package/dist-server/service/chat-message/chat-message-room.js +3 -1
  60. package/dist-server/service/chat-message/chat-message-room.js.map +1 -1
  61. package/dist-server/service/chat-message/chat-typing-subscription.d.ts +10 -0
  62. package/dist-server/service/chat-message/chat-typing-subscription.js +68 -0
  63. package/dist-server/service/chat-message/chat-typing-subscription.js.map +1 -0
  64. package/dist-server/service/chat-message/chat-typing.d.ts +28 -0
  65. package/dist-server/service/chat-message/chat-typing.js +40 -0
  66. package/dist-server/service/chat-message/chat-typing.js.map +1 -0
  67. package/dist-server/service/chat-session/attachment-tool-result.d.ts +1 -0
  68. package/dist-server/service/chat-session/attachment-tool-result.js +6 -0
  69. package/dist-server/service/chat-session/attachment-tool-result.js.map +1 -1
  70. package/dist-server/service/chat-session/attachment-tools.js +2 -2
  71. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  72. package/dist-server/service/chat-session/chat-session.d.ts +2 -1
  73. package/dist-server/service/chat-session/chat-session.js +1 -1
  74. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  75. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +12 -0
  76. package/dist-server/service/chat-session/session-activity-broadcast.js +15 -0
  77. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -1
  78. package/dist-server/service/chat-session/session-activity-publish.d.ts +2 -0
  79. package/dist-server/service/chat-session/session-activity-publish.js +5 -2
  80. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -1
  81. package/dist-server/service/chat-session/session-visibility.d.ts +17 -2
  82. package/dist-server/service/chat-session/session-visibility.js +28 -3
  83. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  84. package/dist-server/service/chat-session/session-write-gate.d.ts +1 -1
  85. package/dist-server/service/chat-session/session-write-gate.js +1 -1
  86. package/dist-server/service/chat-session/session-write-gate.js.map +1 -1
  87. package/dist-server/service/chat-session-participant/index.d.ts +1 -0
  88. package/dist-server/service/chat-session-participant/index.js +4 -1
  89. package/dist-server/service/chat-session-participant/index.js.map +1 -1
  90. package/dist-server/service/chat-session-participant/invite-notification.d.ts +16 -1
  91. package/dist-server/service/chat-session-participant/invite-notification.js +34 -5
  92. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  93. package/dist-server/service/chat-session-participant/mention-invite.d.ts +30 -4
  94. package/dist-server/service/chat-session-participant/mention-invite.js +78 -31
  95. package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -1
  96. package/dist-server/service/chat-session-participant/read-roster.js +5 -2
  97. package/dist-server/service/chat-session-participant/read-roster.js.map +1 -1
  98. package/dist-server/service/chat-session-resolver.d.ts +27 -0
  99. package/dist-server/service/chat-session-resolver.js +226 -25
  100. package/dist-server/service/chat-session-resolver.js.map +1 -1
  101. package/dist-server/service/data-entry/entry-target.d.ts +19 -0
  102. package/dist-server/service/data-entry/entry-target.js +20 -5
  103. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  104. package/dist-server/service/data-entry/entry-tools.js +2 -2
  105. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  106. package/dist-server/service/data-entry/entry-update.d.ts +2 -1
  107. package/dist-server/service/data-entry/entry-update.js +5 -3
  108. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  109. package/dist-server/service/data-read/list-read.d.ts +10 -0
  110. package/dist-server/service/data-read/list-read.js +12 -0
  111. package/dist-server/service/data-read/list-read.js.map +1 -1
  112. package/dist-server/service/data-read/read-target.d.ts +7 -0
  113. package/dist-server/service/data-read/read-target.js +2 -2
  114. package/dist-server/service/data-read/read-target.js.map +1 -1
  115. package/dist-server/service/data-read/read-tools.js +1 -1
  116. package/dist-server/service/data-read/read-tools.js.map +1 -1
  117. package/dist-server/service/index.d.ts +4 -2
  118. package/dist-server/service/index.js +9 -1
  119. package/dist-server/service/index.js.map +1 -1
  120. package/dist-server/service/testing/conversation-script.d.ts +1 -1
  121. package/dist-server/service/testing/conversation-script.js.map +1 -1
  122. package/dist-server/service/testing/mode-conformance.js +4 -0
  123. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  124. package/dist-server/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +9 -9
  126. package/server/explicit-graphql-types.test.ts +1 -1
  127. package/server/service/assistant-chat-resolver.ts +7 -12
  128. package/server/service/assistant-mode/index.ts +1 -1
  129. package/server/service/assistant-mode/mode-categories.ts +11 -3
  130. package/server/service/assistant-mode/mode-front.test.ts +53 -1
  131. package/server/service/assistant-mode/resolve.test.ts +2 -1
  132. package/server/service/assistant-mode/screen-rules.test.ts +41 -3
  133. package/server/service/assistant-mode/screen-rules.ts +44 -5
  134. package/server/service/chat-message/chat-message-room.test.ts +7 -2
  135. package/server/service/chat-message/chat-message-room.ts +3 -1
  136. package/server/service/chat-message/chat-typing-subscription.ts +52 -0
  137. package/server/service/chat-message/chat-typing.test.ts +45 -0
  138. package/server/service/chat-message/chat-typing.ts +48 -0
  139. package/server/service/chat-message/fold-history-window.test.ts +1 -1
  140. package/server/service/chat-session/attachment-tool-result.test.ts +9 -1
  141. package/server/service/chat-session/attachment-tool-result.ts +7 -0
  142. package/server/service/chat-session/attachment-tools.ts +3 -3
  143. package/server/service/chat-session/chat-session.ts +3 -2
  144. package/server/service/chat-session/group-room.test.ts +233 -0
  145. package/server/service/chat-session/session-activity-broadcast.ts +20 -0
  146. package/server/service/chat-session/session-activity-publish.ts +6 -1
  147. package/server/service/chat-session/session-activity-receivers.test.ts +26 -0
  148. package/server/service/chat-session/session-visibility.test.ts +32 -1
  149. package/server/service/chat-session/session-visibility.ts +33 -4
  150. package/server/service/chat-session/session-write-gate.test.ts +7 -1
  151. package/server/service/chat-session/session-write-gate.ts +2 -2
  152. package/server/service/chat-session-participant/index.ts +2 -0
  153. package/server/service/chat-session-participant/invite-notification.test.ts +36 -1
  154. package/server/service/chat-session-participant/invite-notification.ts +43 -5
  155. package/server/service/chat-session-participant/mention-invite.test.ts +49 -1
  156. package/server/service/chat-session-participant/mention-invite.ts +82 -28
  157. package/server/service/chat-session-participant/read-roster.test.ts +13 -4
  158. package/server/service/chat-session-participant/read-roster.ts +6 -3
  159. package/server/service/chat-session-resolver.ts +204 -27
  160. package/server/service/data-entry/data-entry-update.test.ts +19 -0
  161. package/server/service/data-entry/data-entry.test.ts +65 -1
  162. package/server/service/data-entry/entry-target.ts +36 -5
  163. package/server/service/data-entry/entry-tools.ts +2 -2
  164. package/server/service/data-entry/entry-update.ts +6 -3
  165. package/server/service/data-read/list-missing.test.ts +49 -0
  166. package/server/service/data-read/list-read.ts +18 -0
  167. package/server/service/data-read/read-target.ts +10 -3
  168. package/server/service/data-read/read-tools.ts +1 -1
  169. package/server/service/data-read/record-keys.test.ts +36 -0
  170. package/server/service/index.ts +6 -1
  171. package/server/service/testing/conversation-script.ts +1 -1
  172. package/server/service/testing/mode-conformance.ts +2 -0
  173. package/test/assistant-dock.test.ts +5 -1
  174. package/test/assistant-session-manager.test.ts +4 -2
  175. package/test/open-doors.test.ts +10 -5
  176. package/translations/en.json +29 -4
  177. package/translations/es.json +29 -4
  178. package/translations/ja.json +29 -4
  179. package/translations/ko.json +30 -5
  180. package/translations/ms.json +29 -4
  181. package/translations/zh.json +29 -4
@@ -144,7 +144,8 @@ export class ChatSession {
144
144
  * 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.
145
145
  *
146
146
  * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.
147
- * `private` 참여자만 읽는다.
147
+ * `group` owner 가 들인 사람과 owner 만 읽고 말한다(2026-10-24, ADR-0073 결정 3).
148
+ * `private` owner 혼자. 아무도 앉히지 않고, 그룹을 비공개로 바꾸면 들인 사람이 나간다.
148
149
  *
149
150
  * 새 세션은 **모두** `private` 으로 시작한다(2026-09-30 — `initialVisibility`). 앵커는 무엇에 대한
150
151
  * 이야기인지를 말할 뿐 누구에게 보이는지를 정하지 않는다. 칸의 기본값 `open` 은 칸이 서기 전의 옛 행을
@@ -156,7 +157,7 @@ export class ChatSession {
156
157
  * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.
157
158
  */
158
159
  @Column({ type: 'varchar', length: 16, default: 'open' })
159
- @Field({ description: "Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only)." })
160
+ @Field({ description: "Who may read this conversation: 'open' (anyone in the domain), 'group' (the owner and the people the owner brought in) or 'private' (the owner alone)." })
160
161
  visibility!: string
161
162
 
162
163
  /** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */
@@ -0,0 +1,233 @@
1
+ /*
2
+ * A group conversation (ADR-0073 decision 3, 2026-10-24) - through the doors themselves, on sqlite.
3
+ *
4
+ * The owner brings people in; only they and the owner read, write, see the roster, hear the room and press a
5
+ * card there. Everyone else in the domain is refused at every one of those doors - asked for the room by its
6
+ * id, which is how a person outside it would try. A member's mention tells, it does not seat. Private is the
7
+ * owner's alone: turning a group private sends its members out.
8
+ */
9
+ import type { DataSource } from 'typeorm'
10
+
11
+ const { DataSource: TypeOrmDataSource } = require('typeorm')
12
+ const shell = require('@things-factory/shell')
13
+ const authBase = require('@things-factory/auth-base')
14
+
15
+ /* The resolvers and entities are type-graphql classes and need tsc's decorator metadata - the compiled modules are read. */
16
+ function loadCompiled(modulePath: string): any {
17
+ const { existsSync } = require('fs')
18
+ const { join } = require('path')
19
+ const resolved = join(__dirname, '..', '..', '..', 'dist-server', 'service', `${modulePath}.js`)
20
+ if (!existsSync(resolved)) throw new Error(`ai-assistant has no build output: ${resolved}\nBuild first:\n yarn workspace @things-factory/ai-assistant build:server`)
21
+ return require(resolved)
22
+ }
23
+ const { ChatSession } = loadCompiled('chat-session/chat-session')
24
+ const { ChatMessage } = loadCompiled('chat-message/chat-message')
25
+ const { ChatSessionParticipant } = loadCompiled('chat-session-participant/chat-session-participant')
26
+ const { ChatSessionResolver } = loadCompiled('chat-session-resolver')
27
+ const { EntryProposalResolver } = loadCompiled('data-entry/entry-resolver')
28
+ const { chatRoomLookup } = loadCompiled('chat-message/chat-message-room')
29
+ const { mayHearRoom } = loadCompiled('chat-message/chat-message-broadcast')
30
+ const { declareEntryTarget, clearEntryTargets } = loadCompiled('data-entry/entry-target')
31
+
32
+ let db: DataSource
33
+ let domain: any
34
+ let owner: any
35
+ let guest: any
36
+ let outsider: any
37
+ let notices: any[]
38
+ /** Whose request state the application's mutation ran with, one id per row entered. */
39
+ let enteredAs: string[]
40
+
41
+ const doors = new ChatSessionResolver()
42
+ const entry = new EntryProposalResolver()
43
+
44
+ /** A request as that person - the state a door reads. `notify` keeps what an invite sends. */
45
+ const as = (user: any) => ({ state: { domain, user, notify: async (payload: any) => notices.push(payload) } }) as any
46
+
47
+ beforeAll(async () => {
48
+ db = new TypeOrmDataSource({
49
+ type: 'sqlite',
50
+ database: ':memory:',
51
+ synchronize: true,
52
+ dropSchema: true,
53
+ namingStrategy: new shell.NamingStrategy(),
54
+ entities: [...shell.entities, ...authBase.entities, ChatSession, ChatMessage, ChatSessionParticipant],
55
+ logging: false
56
+ })
57
+ await db.initialize()
58
+ shell.addDataSource('default', db)
59
+ domain = await db.getRepository(shell.Domain).save({ name: 'acme', subdomain: 'acme' })
60
+ const person = (name: string) =>
61
+ db.getRepository(authBase.User).save({ name, username: name, email: `${name}@example.com`, status: 'activated', domains: [domain] })
62
+ /* A card's target: the application's own mutation, entered through the local client as the person who pressed. */
63
+ process['PRIVILEGE_OF_FIELD']['Mutation.addNote'] = null
64
+ declareEntryTarget({
65
+ key: 'test.note',
66
+ labelKey: 'label.note',
67
+ description: 'A note.',
68
+ fields: [{ name: 'title', type: 'text', required: true }],
69
+ mutation: 'mutation ($title: String!) { addNote(title: $title) { id } }',
70
+ variables: (row: any) => ({ title: row.values.title }),
71
+ appliedOf: (data: any) => ({ id: data?.addNote?.id })
72
+ })
73
+ shell.GraphqlLocalClient.client = {
74
+ mutate: async ({ context }: any) => {
75
+ enteredAs.push(context.state.user.id)
76
+ return { data: { addNote: { id: 'note-1' } } }
77
+ }
78
+ }
79
+ owner = await person('owner')
80
+ guest = await person('guest')
81
+ outsider = await person('outsider')
82
+ })
83
+
84
+ afterAll(async () => {
85
+ clearEntryTargets()
86
+ delete process['PRIVILEGE_OF_FIELD']['Mutation.addNote']
87
+ shell.removeDataSource('default')
88
+ await db?.destroy()
89
+ })
90
+
91
+ let session: any
92
+
93
+ /** A group conversation the owner started, with one message in it. */
94
+ beforeEach(async () => {
95
+ notices = []
96
+ enteredAs = []
97
+ session = await db.getRepository(ChatSession).save({ domain, anchorType: 'domain', anchorId: domain.id, name: 'pricing', visibility: 'group', creator: owner, lastMessageAt: new Date() })
98
+ await db.getRepository(ChatSessionParticipant).save({ domain, session: { id: session.id }, user: { id: owner.id }, role: 'owner', creator: owner })
99
+ await db.getRepository(ChatMessage).save({ session: { id: session.id }, role: 'user', content: 'the margin is 12%', creator: owner })
100
+ await db.getRepository(ChatMessage).save({
101
+ session: { id: session.id },
102
+ role: 'assistant',
103
+ content: 'one note to enter',
104
+ proposals: JSON.stringify([{ key: 'p-1', kind: 'entry', target: 'test.note', rows: [{ values: { title: 'margin' }, sources: [] }] }]),
105
+ creator: owner
106
+ })
107
+ })
108
+
109
+ const roomHears = (user: any) =>
110
+ mayHearRoom(chatRoomLookup({ sessions: db.getRepository(ChatSession), participants: db.getRepository(ChatSessionParticipant) }, { domainId: domain.id, sessionId: session.id, userId: user.id }))
111
+ const inbox = async (user: any) => (await doors.chatSessionInbox('domain', domain.id, as(user))).map((row: any) => row.id)
112
+
113
+ /** Everything a person outside the room is refused - each door asked by the room's id. */
114
+ async function expectOutside(user: any) {
115
+ expect(await doors.chatMessages(session.id, 100, 0, as(user))).toEqual([])
116
+ expect(await doors.chatSession(session.id, as(user))).toBeNull()
117
+ expect(await doors.chatSessionParticipants(session.id, as(user))).toEqual([])
118
+ expect(await inbox(user)).not.toContain(session.id)
119
+ expect(await roomHears(user)).toBe(false)
120
+ await expect(doors.sayToRoom(session.id, 'let me in', undefined, as(user))).rejects.toThrow(/private/)
121
+ await expect(doors.sayTyping(session.id, as(user))).rejects.toThrow(/private/)
122
+ await expect(entry.applyEntryProposal(session.id, 'p-1', as(user))).rejects.toThrow(/private/)
123
+ expect(enteredAs).not.toContain(user.id)
124
+ }
125
+
126
+ describe('a group conversation', () => {
127
+ it('★ someone the owner did not bring in reads, writes, hears and presses nothing', async () => {
128
+ await expectOutside(outsider)
129
+ await expectOutside(guest)
130
+ })
131
+
132
+ it('★ the owner brings someone in - they are told, and from then on they read and write', async () => {
133
+ expect(await doors.inviteToAISession(session.id, [guest.id], as(owner))).toEqual([guest.id])
134
+ expect(notices.map(n => n.mode)).toEqual(['foreground', 'background'])
135
+ expect(notices[0].receivers).toEqual([guest.id])
136
+ expect(notices[0].body).toContain('pricing')
137
+
138
+ expect((await doors.chatMessages(session.id, 100, 0, as(guest))).map((m: any) => m.content).sort()).toEqual(['one note to enter', 'the margin is 12%'])
139
+ expect(await doors.chatSession(session.id, as(guest))).not.toBeNull()
140
+ expect(await inbox(guest)).toContain(session.id)
141
+ expect(await roomHears(guest)).toBe(true)
142
+ expect((await doors.chatSessionParticipants(session.id, as(guest))).map((p: any) => p.role).sort()).toEqual(['member', 'owner'])
143
+ expect(await doors.sayToRoom(session.id, 'agreed', undefined, as(guest))).toBeTruthy()
144
+ expect(await doors.sayTyping(session.id, as(guest))).toBe(true)
145
+
146
+ /* ★ The card the owner's conversation made is pressed by the guest - and written as the guest, never as the owner. */
147
+ expect(await entry.applyEntryProposal(session.id, 'p-1', as(guest))).toMatchObject({ verdict: 'accepted', applied: 1 })
148
+ expect(enteredAs).toEqual([guest.id])
149
+
150
+ /* The one brought in is still not the one who brings people in. */
151
+ await expectOutside(outsider)
152
+ })
153
+
154
+ it('★ a member cannot bring someone in - not by the door and not by a mention', async () => {
155
+ await doors.inviteToAISession(session.id, [guest.id], as(owner))
156
+ expect(await doors.mayInviteToAISession(session.id, as(owner))).toBe(true)
157
+ expect(await doors.mayInviteToAISession(session.id, as(guest))).toBe(false)
158
+ await expect(doors.inviteToAISession(session.id, [outsider.id], as(guest))).rejects.toThrow(/only the person who started/)
159
+ await doors.sayToRoom(session.id, `ask @outsider{userId:${outsider.id}}`, undefined, as(guest))
160
+ await expectOutside(outsider)
161
+ })
162
+
163
+ it("the owner's mention seats, as the invite does", async () => {
164
+ await doors.sayToRoom(session.id, `see @guest{userId:${guest.id}}`, undefined, as(owner))
165
+ expect(await roomHears(guest)).toBe(true)
166
+ expect(notices.map(n => n.receivers)).toEqual([[guest.id], [guest.id]])
167
+ })
168
+
169
+ it('a person outside the domain is not seated', async () => {
170
+ const stranger = await db.getRepository(authBase.User).save({ name: 'stranger', username: 'stranger', email: 'stranger@example.com', status: 'activated' })
171
+ expect(await doors.inviteToAISession(session.id, [stranger.id], as(owner))).toEqual([])
172
+ })
173
+
174
+ it('★ sent out or gone out, the seat closes again', async () => {
175
+ await doors.inviteToAISession(session.id, [guest.id, outsider.id], as(owner))
176
+ await expect(doors.removeFromAISession(session.id, outsider.id, as(guest))).rejects.toThrow(/only the person who started/)
177
+ expect(await doors.removeFromAISession(session.id, outsider.id, as(owner))).toBe(true)
178
+ await expectOutside(outsider)
179
+
180
+ expect(await doors.leaveAISession(session.id, as(guest))).toBe(true)
181
+ await expectOutside(guest)
182
+
183
+ await expect(doors.leaveAISession(session.id, as(owner))).rejects.toThrow(/cannot leave/)
184
+ })
185
+
186
+ it('a hide-only row is not a seat - hiding a group does not open it', async () => {
187
+ await doors.hideAISession(session.id, as(outsider), true)
188
+ await expectOutside(outsider)
189
+ })
190
+ })
191
+
192
+ describe('private is the owner alone', () => {
193
+ it('★ turning a group private sends its members out', async () => {
194
+ await doors.inviteToAISession(session.id, [guest.id], as(owner))
195
+ expect(await doors.setSessionVisibility(session.id, 'private', as(owner))).toBe(true)
196
+ await expectOutside(guest)
197
+ expect((await doors.chatSessionParticipants(session.id, as(owner))).map((p: any) => p.role)).toEqual(['owner'])
198
+ })
199
+
200
+ it('★ a private room takes nobody - make it a group first', async () => {
201
+ await doors.setSessionVisibility(session.id, 'private', as(owner))
202
+ expect(await doors.mayInviteToAISession(session.id, as(owner))).toBe(false)
203
+ await expect(doors.inviteToAISession(session.id, [guest.id], as(owner))).rejects.toThrow(/make it a group/)
204
+ await doors.sayToRoom(session.id, `@guest{userId:${guest.id}}`, undefined, as(owner))
205
+ await expectOutside(guest)
206
+
207
+ await doors.setSessionVisibility(session.id, 'group', as(owner))
208
+ expect(await doors.inviteToAISession(session.id, [guest.id], as(owner))).toEqual([guest.id])
209
+ expect(await roomHears(guest)).toBe(true)
210
+ })
211
+
212
+ it('only the owner changes who reads it, and only to the three it knows', async () => {
213
+ await doors.inviteToAISession(session.id, [guest.id], as(owner))
214
+ expect(await doors.setSessionVisibility(session.id, 'open', as(guest))).toBe(false)
215
+ await expect(doors.setSessionVisibility(session.id, 'team', as(owner))).rejects.toThrow(/unknown visibility/)
216
+ })
217
+ })
218
+
219
+ describe('an open room', () => {
220
+ it('★ someone who walks in and speaks is in the roster after that - the writers and the roster agree', async () => {
221
+ await doors.setSessionVisibility(session.id, 'open', as(owner))
222
+ await doors.sayToRoom(session.id, 'I read it too', undefined, as(outsider))
223
+ const roster = await doors.chatSessionParticipants(session.id, as(owner))
224
+ expect(roster.map((p: any) => [p.userId, p.role]).sort()).toEqual([[outsider.id, 'member'], [owner.id, 'owner']].sort())
225
+ expect(await doors.touchPresence(session.id, as(outsider))).toBe(true)
226
+ })
227
+
228
+ it('anyone in the domain reads it and anyone may point a colleague at it', async () => {
229
+ await doors.setSessionVisibility(session.id, 'open', as(owner))
230
+ expect((await doors.chatMessages(session.id, 100, 0, as(outsider))).length).toBe(2)
231
+ expect(await doors.inviteToAISession(session.id, [guest.id], as(outsider))).toEqual([guest.id])
232
+ })
233
+ })
@@ -16,6 +16,9 @@
16
16
  * 참여자 조회는 발행당 1회 — 사람 대화 빈도에서 감당 가능한 비용이다.
17
17
  */
18
18
 
19
+ import { isOpenRoom } from './session-visibility.js'
20
+ import { isRoomRole } from '../chat-session-participant/participant-lookup-roles.js'
21
+
19
22
  /** 세션 활동 방송 토픽. */
20
23
  export const SESSION_ACTIVITY_TOPIC = 'board-ai-session-activity'
21
24
 
@@ -44,6 +47,23 @@ export interface SessionActivityPayload {
44
47
  actorId?: string
45
48
  }
46
49
 
50
+ /**
51
+ * Who hears a session's activity - pure, so the rule is tested alone.
52
+ *
53
+ * A `viewer` row only remembers a hide. In an open room its person may read anyway, so they hear (a hidden
54
+ * conversation comes back on new activity). In a group or private room they are outside, and the notice
55
+ * carries a preview - they do not hear it. `alsoTo` is the people who just left, told once so their list
56
+ * drops the conversation.
57
+ */
58
+ export function activityReceivers(
59
+ rows: ReadonlyArray<{ userId?: string; role?: string }>,
60
+ visibility: string | null | undefined,
61
+ alsoTo: ReadonlyArray<string> = []
62
+ ): string[] {
63
+ const heard = rows.filter(row => isOpenRoom(visibility) || isRoomRole(row.role)).map(row => row.userId)
64
+ return [...new Set([...heard, ...alsoTo])].filter((id): id is string => !!id)
65
+ }
66
+
47
67
  /**
48
68
  * 같은 도메인 + **내가 참여자인 세션**의 활동만 통과. 도메인 격리가 보안 핵심이고,
49
69
  * 참여자 검사가 "내 것만" 을 성립시킨다(참여하지 않은 대화의 활동을 받지 않는다).
@@ -9,6 +9,8 @@
9
9
  import { pubsub, getRepository } from '@things-factory/shell'
10
10
 
11
11
  import { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'
12
+ import { ChatSession } from './chat-session.js'
13
+ import { activityReceivers } from './session-activity-broadcast.js'
12
14
  import { SESSION_ACTIVITY_TOPIC, type SessionActivityPayload, type SessionActivityKind } from './session-activity-broadcast.js'
13
15
 
14
16
  export async function publishSessionActivity(input: {
@@ -21,16 +23,19 @@ export async function publishSessionActivity(input: {
21
23
  at?: Date
22
24
  preview?: string
23
25
  actorId?: string
26
+ /** People who just left the room - told once more, so their list drops it. They are no longer seated. */
27
+ alsoTo?: string[]
24
28
  }): Promise<void> {
25
29
  try {
26
30
  const participants = await getRepository(ChatSessionParticipant).find({
27
31
  where: { domain: { id: input.domainId } as any, session: { id: input.sessionId } as any }
28
32
  })
33
+ const session = await getRepository(ChatSession).findOne({ where: { id: input.sessionId, domain: { id: input.domainId } } as any })
29
34
  const payload: SessionActivityPayload = {
30
35
  domainId: input.domainId,
31
36
  sessionId: input.sessionId,
32
37
  kind: input.kind ?? 'message',
33
- participantIds: participants.map(p => (p as any).userId).filter(Boolean),
38
+ participantIds: activityReceivers(participants as any, (session as any)?.visibility, input.alsoTo),
34
39
  anchorType: input.anchorType,
35
40
  anchorId: input.anchorId,
36
41
  at: (input.at ?? new Date()).toISOString(),
@@ -0,0 +1,26 @@
1
+ /*
2
+ * Who hears a session's activity. The notice carries a preview, so outside a group or private room a
3
+ * hide-only `viewer` row hears nothing; the people just sent out are told once so their lists drop it.
4
+ */
5
+ import { activityReceivers } from './session-activity-broadcast'
6
+
7
+ const rows = [
8
+ { userId: 'owner', role: 'owner' },
9
+ { userId: 'member', role: 'member' },
10
+ { userId: 'hid-it', role: 'viewer' }
11
+ ]
12
+
13
+ describe('activity receivers', () => {
14
+ it('★ a hide-only row hears nothing of a group or private room', () => {
15
+ expect(activityReceivers(rows, 'group')).toEqual(['owner', 'member'])
16
+ expect(activityReceivers(rows, 'private')).toEqual(['owner', 'member'])
17
+ })
18
+
19
+ it('in an open room a hidden conversation still comes back on new activity', () => {
20
+ expect(activityReceivers(rows, 'open')).toEqual(['owner', 'member', 'hid-it'])
21
+ })
22
+
23
+ it('the people who just left are told once, never twice', () => {
24
+ expect(activityReceivers([{ userId: 'owner', role: 'owner' }], 'private', ['member', 'owner'])).toEqual(['owner', 'member'])
25
+ })
26
+ })
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 옮겨 적어 만든 세션이 공개로 태어나면 사적으로 오간 원문이 소급해서 공개된다. 그 한 줄을 여기서 문다.
5
5
  */
6
- import { initialVisibility, mayReadSession } from './session-visibility'
6
+ import { initialVisibility, isOpenRoom, mayReadSession, maySeat, SESSION_VISIBILITIES } from './session-visibility'
7
7
 
8
8
  describe('session visibility', () => {
9
9
  it('★ 앵커가 있어도 비공개로 시작한다 — 앵커는 무엇에 대한 이야기인지이지 누구에게 보이는지가 아니다', () => {
@@ -35,6 +35,37 @@ describe('session visibility', () => {
35
35
  })
36
36
  })
37
37
 
38
+ /*
39
+ * ADR-0073 decision 3 (2026-10-24) — a group is read by its seats, and only the owner hands a seat out.
40
+ */
41
+ describe('a group room', () => {
42
+ it('three rooms, no more', () => {
43
+ expect([...SESSION_VISIBILITIES]).toEqual(['open', 'group', 'private'])
44
+ })
45
+
46
+ it('★ a group is read by the people seated in it only', () => {
47
+ expect(mayReadSession('group', false)).toBe(false)
48
+ expect(mayReadSession('group', true)).toBe(true)
49
+ })
50
+
51
+ it('★ only an open room skips the seat lookup - a group is not open', () => {
52
+ expect(isOpenRoom('open')).toBe(true)
53
+ expect(isOpenRoom(undefined)).toBe(true)
54
+ expect(isOpenRoom('group')).toBe(false)
55
+ expect(isOpenRoom('private')).toBe(false)
56
+ })
57
+
58
+ it('★ who seats: anyone in an open room, the owner in a group, nobody in a private one', () => {
59
+ expect(maySeat('open', undefined)).toBe(true)
60
+ expect(maySeat('open', 'member')).toBe(true)
61
+ expect(maySeat('group', 'owner')).toBe(true)
62
+ expect(maySeat('group', 'member')).toBe(false)
63
+ expect(maySeat('group', 'viewer')).toBe(false)
64
+ expect(maySeat('group', undefined)).toBe(false)
65
+ expect(maySeat('private', 'owner')).toBe(false)
66
+ })
67
+ })
68
+
38
69
  /*
39
70
  * ADR-0073 결정 2 — 대상 없는 개인 질문은 공개 방으로 태어나지 않는다.
40
71
  *
@@ -4,9 +4,15 @@
4
4
  * 판정을 세션을 만드는 자리마다 적으면 한 곳이 빠진 날 그 세션만 다른 규칙으로 산다. 한 줄로 모은다.
5
5
  */
6
6
 
7
- export type SessionVisibility = 'open' | 'private'
7
+ /*
8
+ * Three rooms (2026-10-24, the user - ADR-0073 decision 3):
9
+ * open anyone in the domain walks in and writes.
10
+ * group the people the owner brought in, and the owner.
11
+ * private the owner alone. Nobody is seated, and turning a group private sends its members out.
12
+ */
13
+ export type SessionVisibility = 'open' | 'group' | 'private'
8
14
 
9
- export const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'private']
15
+ export const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'group', 'private']
10
16
 
11
17
  /**
12
18
  * 이렇게 만들어진 세션은 무엇으로 시작하나 — **언제나 비공개.**
@@ -22,9 +28,32 @@ export function initialVisibility(_session: { anchorType?: string | null; copyOf
22
28
  return 'private'
23
29
  }
24
30
 
25
- /** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */
31
+ /**
32
+ * Is the room open to everyone in the domain - so no seat needs looking up?
33
+ *
34
+ * Doors skip the participant lookup for an open room. They asked `visibility === 'private'` for it, and a
35
+ * group room is neither: asked that way, a group conversation read like an open one. They ask here.
36
+ */
37
+ export function isOpenRoom(visibility: string | null | undefined): boolean {
38
+ return (visibility ?? 'open') === 'open'
39
+ }
40
+
41
+ /** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, group · private 이면 앉은 사람만. */
26
42
  export function mayReadSession(visibility: string | null | undefined, isParticipant: boolean): boolean {
27
- return (visibility ?? 'open') === 'open' || isParticipant
43
+ return isOpenRoom(visibility) || isParticipant
44
+ }
45
+
46
+ /**
47
+ * May this person seat someone in this conversation - by a mention or by inviting them?
48
+ *
49
+ * In an open room a seat grants nothing (anyone walks in), so anyone may point a colleague at it. In a group
50
+ * the seat is the key, and only the owner hands it out - a member's mention tells, it does not seat. A private
51
+ * room seats nobody: it is the owner's alone. Both seating doors ask this one line.
52
+ */
53
+ export function maySeat(visibility: string | null | undefined, seatRole: string | null | undefined): boolean {
54
+ if (isOpenRoom(visibility)) return true
55
+ if (visibility === 'group') return seatRole === 'owner'
56
+ return false
28
57
  }
29
58
 
30
59
  /**
@@ -25,7 +25,13 @@ describe('who may write into a conversation', () => {
25
25
  it('★ an outsider is refused on a private one, by a sentence that says what to do', async () => {
26
26
  await expect(
27
27
  assertMayWriteSession({ session: { visibility: 'private' }, userId: 'bob', isParticipant: async () => false, t })
28
- ).rejects.toThrow(/private - only the people in it can write here/)
28
+ ).rejects.toThrow(/only they can write here\. ask the person who started it to bring you in/)
29
+ })
30
+
31
+ it('★ an outsider is refused on a group one too - a group is not an open room', async () => {
32
+ await expect(
33
+ assertMayWriteSession({ session: { visibility: 'group' }, userId: 'bob', isParticipant: async () => false, t })
34
+ ).rejects.toThrow(/private to the people in it/)
29
35
  })
30
36
 
31
37
  it('a participant writes into a private one', async () => {
@@ -10,7 +10,7 @@ import { mayWriteSession } from './session-visibility.js'
10
10
  export interface WriteGateInput {
11
11
  session: { id?: string; visibility?: string | null }
12
12
  userId?: string
13
- /** Is this user a participant of the session (owner or member)? Asked only for a private one. */
13
+ /** Is this user a participant of the session (owner or member)? Asked only for a group or private one. */
14
14
  isParticipant: () => Promise<boolean>
15
15
  t: (key: string, options?: Record<string, unknown>) => string
16
16
  }
@@ -24,7 +24,7 @@ export async function assertMayWriteSession({ session, userId, isParticipant, t
24
24
  if (!mayWriteSession(visibility, participant)) {
25
25
  throw new Error(
26
26
  t('ai-assistant.text.private-session-refused', {
27
- defaultValue: 'this conversation is private - only the people in it can write here. ask one of them to bring you in.'
27
+ defaultValue: 'this conversation is private to the people in it - only they can write here. ask the person who started it to bring you in.'
28
28
  })
29
29
  )
30
30
  }
@@ -2,5 +2,7 @@ import { ChatSessionParticipant } from './chat-session-participant.js'
2
2
 
3
3
  export { ChatSessionParticipant }
4
4
  export { isOnline, PRESENCE_TTL_MS } from './presence.js'
5
+ /* "Is this person in the room" - one lookup for every gate, board-ai's revert included (a `viewer` row is not a seat). */
6
+ export { isSessionParticipant } from './participant-lookup.js'
5
7
 
6
8
  export const entities = [ChatSessionParticipant]
@@ -1,4 +1,4 @@
1
- import { buildInviteNotification } from './invite-notification'
1
+ import { buildInviteNotification, sendInviteNotification } from './invite-notification'
2
2
 
3
3
  describe('buildInviteNotification — 협업 초대 알림 payload', () => {
4
4
  test('받는 사람 없으면 null', () => {
@@ -43,3 +43,38 @@ describe('an invite outside a board', () => {
43
43
  expect(buildInviteNotification(['u1'], undefined, { name: 'Kim' })!.body).toBe('Kim brought you into a conversation.')
44
44
  })
45
45
  })
46
+
47
+ describe('sendInviteNotification — the list and the push', () => {
48
+ test('★ the invite reaches the app\'s notification list (foreground) and the push (background)', async () => {
49
+ const sent: any[] = []
50
+ await sendInviteNotification(async (payload: any) => void sent.push(payload), ['u1'], { name: 'Kim' }, 'pricing')
51
+ expect(sent.map(one => one.mode)).toEqual(['foreground', 'background'])
52
+ expect(sent.every(one => one.receivers.join() === 'u1' && one.body.includes('pricing'))).toBe(true)
53
+ })
54
+
55
+ test('a failed way does not stop the other, and nobody to tell sends nothing', async () => {
56
+ const sent: any[] = []
57
+ await sendInviteNotification(async (payload: any) => {
58
+ if (payload.mode === 'foreground') throw new Error('down')
59
+ sent.push(payload)
60
+ }, ['u1'], null)
61
+ expect(sent.map(one => one.mode)).toEqual(['background'])
62
+ await sendInviteNotification(async () => { throw new Error('never') }, [], null)
63
+ await sendInviteNotification(undefined, ['u1'], null)
64
+ })
65
+ })
66
+
67
+ describe('the invite in the reader\'s words', () => {
68
+ test('★ the words go through the request\'s translator, the names filled in', () => {
69
+ const asked: any[] = []
70
+ const t = (key: string, options: any) => (asked.push([key, options]), `${key}:${options.who ?? ''}:${options.name ?? ''}`)
71
+ const p = buildInviteNotification(['u1'], undefined, { name: 'Kim' }, 'pricing', t)!
72
+ expect(p.title).toBe('ai-assistant.text.invite-title::')
73
+ expect(p.body).toBe('ai-assistant.text.invite-body-named:Kim:pricing')
74
+ expect(buildInviteNotification(['u1'], 'b1', { name: 'Kim' }, undefined, t)!.body).toBe('ai-assistant.text.board-invite-body:Kim:')
75
+ })
76
+
77
+ test('without a translator the English words, names filled in', () => {
78
+ expect(buildInviteNotification(['u1'], undefined, { name: 'Kim' }, 'pricing')!.body).toBe('Kim brought you into the conversation "pricing".')
79
+ })
80
+ })
@@ -23,19 +23,28 @@ export interface Inviter {
23
23
  email?: string | null
24
24
  }
25
25
 
26
+ /** The request's translator - `requestTranslator(context)`. Absent: the English words. */
27
+ export type InviteTranslate = (key: string, options: Record<string, unknown>) => string
28
+
26
29
  /**
27
30
  * 초대된 사용자들에게 보낼 협업 초대 알림 payload 를 만든다.
28
31
  * 받는 사람이 없으면 null (= 보낼 것 없음).
32
+ *
33
+ * The words are the inviting request's language (`t`). The people of one domain read one language; a receiver's
34
+ * own locale is not asked here (2026-10-24: the English words stood on a Korean screen).
29
35
  */
30
36
  export function buildInviteNotification(
31
37
  userIds: string[],
32
38
  boardId: string | undefined,
33
39
  inviter: Inviter | undefined | null,
34
- sessionName?: string | null
40
+ sessionName?: string | null,
41
+ t?: InviteTranslate
35
42
  ): InviteNotification | null {
36
43
  if (!Array.isArray(userIds) || userIds.length === 0) return null
37
44
 
38
45
  const who = inviter?.name || inviter?.email || 'someone'
46
+ const say = (key: string, defaultValue: string, params: Record<string, string> = {}) =>
47
+ t ? t(`ai-assistant.text.${key}`, { ...params, defaultValue }) : defaultValue.replace(/\{(\w+)\}/g, (_, name) => params[name] ?? '')
39
48
 
40
49
  /* A board conversation keeps its board wording and the link to the modeller. */
41
50
  if (boardId) {
@@ -43,8 +52,8 @@ export function buildInviteNotification(
43
52
  receivers: userIds,
44
53
  type: 'board-collab-invite',
45
54
  subject: 'info',
46
- title: 'Board collaboration invite',
47
- body: `${who} invited you to collaborate on a board.`,
55
+ title: say('board-invite-title', 'Board collaboration invite'),
56
+ body: say('board-invite-body', '{who} invited you to collaborate on a board.', { who }),
48
57
  url: `board-modeller/${boardId}`,
49
58
  mode: 'background'
50
59
  }
@@ -59,8 +68,37 @@ export function buildInviteNotification(
59
68
  receivers: userIds,
60
69
  type: 'chat-invite',
61
70
  subject: 'info',
62
- title: 'Conversation invite',
63
- body: name ? `${who} brought you into the conversation "${name}".` : `${who} brought you into a conversation.`,
71
+ title: say('invite-title', 'Conversation invite'),
72
+ body: name
73
+ ? say('invite-body-named', '{who} brought you into the conversation "{name}".', { who, name })
74
+ : say('invite-body', '{who} brought you into a conversation.', { who }),
64
75
  mode: 'background'
65
76
  }
66
77
  }
78
+
79
+ /**
80
+ * Tell the people just seated - in the app's notification list while they are in it (`foreground`, published to
81
+ * them live) and by web push when they are not (`background`). The notifier takes one way per call, so it is
82
+ * called once for each; the list was the one place an invite never reached (2026-10-24: only push was sent).
83
+ *
84
+ * A letter that does not arrive does not undo the seat - the roster is the record, the notice only tells of it.
85
+ */
86
+ export async function sendInviteNotification(
87
+ notify: ((payload: InviteNotification) => Promise<unknown> | unknown) | undefined,
88
+ userIds: string[],
89
+ inviter: Inviter | undefined | null,
90
+ sessionName?: string | null,
91
+ /** A board conversation keeps its board wording and the link to the modeller. */
92
+ boardId?: string,
93
+ t?: InviteTranslate
94
+ ): Promise<void> {
95
+ const payload = buildInviteNotification(userIds, boardId, inviter, sessionName, t)
96
+ if (!payload || !notify) return
97
+ for (const mode of ['foreground', 'background'] as const) {
98
+ try {
99
+ await notify({ ...payload, mode })
100
+ } catch {
101
+ /* the other way may still reach them */
102
+ }
103
+ }
104
+ }