@things-factory/ai-assistant 10.1.47 → 10.1.52

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 (160) hide show
  1. package/client/components/assistant-chat.ts +97 -19
  2. package/client/components/assistant-dock.ts +617 -0
  3. package/client/components/assistant-mode.ts +2 -0
  4. package/client/components/assistant-session-manager.ts +607 -0
  5. package/client/components/chat-echo-dedup.ts +4 -0
  6. package/client/components/chat-input-builder.ts +4 -1
  7. package/client/components/panel-aria.test.ts +13 -8
  8. package/client/index.ts +8 -0
  9. package/client/setup-assistant-dock.ts +118 -0
  10. package/client/utils/assistant-activity.ts +122 -0
  11. package/client/utils/assistant-ask.ts +44 -0
  12. package/client/utils/assistant-proposals.ts +50 -0
  13. package/client/utils/assistant-session-actions.ts +18 -0
  14. package/client/utils/assistant-session-admin.ts +167 -0
  15. package/client/utils/assistant-session-controller.ts +2 -0
  16. package/client/utils/assistant-session-transport.ts +6 -2
  17. package/client/utils/assistant-surface.ts +157 -0
  18. package/client/utils/attachment-policy.ts +71 -0
  19. package/dist-client/components/assistant-chat.d.ts +6 -0
  20. package/dist-client/components/assistant-chat.js +98 -20
  21. package/dist-client/components/assistant-chat.js.map +1 -1
  22. package/dist-client/components/assistant-dock.d.ts +74 -0
  23. package/dist-client/components/assistant-dock.js +641 -0
  24. package/dist-client/components/assistant-dock.js.map +1 -0
  25. package/dist-client/components/assistant-mode.d.ts +6 -0
  26. package/dist-client/components/assistant-mode.js.map +1 -1
  27. package/dist-client/components/assistant-session-manager.d.ts +77 -0
  28. package/dist-client/components/assistant-session-manager.js +652 -0
  29. package/dist-client/components/assistant-session-manager.js.map +1 -0
  30. package/dist-client/components/chat-echo-dedup.js +4 -0
  31. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  32. package/dist-client/components/chat-input-builder.d.ts +2 -0
  33. package/dist-client/components/chat-input-builder.js +2 -1
  34. package/dist-client/components/chat-input-builder.js.map +1 -1
  35. package/dist-client/components/panel-aria.test.js +12 -8
  36. package/dist-client/components/panel-aria.test.js.map +1 -1
  37. package/dist-client/index.d.ts +8 -0
  38. package/dist-client/index.js +8 -0
  39. package/dist-client/index.js.map +1 -1
  40. package/dist-client/setup-assistant-dock.d.ts +15 -0
  41. package/dist-client/setup-assistant-dock.js +119 -0
  42. package/dist-client/setup-assistant-dock.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/dist-client/utils/assistant-activity.d.ts +22 -0
  45. package/dist-client/utils/assistant-activity.js +104 -0
  46. package/dist-client/utils/assistant-activity.js.map +1 -0
  47. package/dist-client/utils/assistant-ask.d.ts +23 -0
  48. package/dist-client/utils/assistant-ask.js +18 -0
  49. package/dist-client/utils/assistant-ask.js.map +1 -0
  50. package/dist-client/utils/assistant-proposals.d.ts +29 -0
  51. package/dist-client/utils/assistant-proposals.js +22 -0
  52. package/dist-client/utils/assistant-proposals.js.map +1 -0
  53. package/dist-client/utils/assistant-session-actions.d.ts +12 -0
  54. package/dist-client/utils/assistant-session-actions.js +19 -0
  55. package/dist-client/utils/assistant-session-actions.js.map +1 -0
  56. package/dist-client/utils/assistant-session-admin.d.ts +72 -0
  57. package/dist-client/utils/assistant-session-admin.js +81 -0
  58. package/dist-client/utils/assistant-session-admin.js.map +1 -0
  59. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  60. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  61. package/dist-client/utils/assistant-session-transport.js +6 -2
  62. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  63. package/dist-client/utils/assistant-surface.d.ts +72 -0
  64. package/dist-client/utils/assistant-surface.js +99 -0
  65. package/dist-client/utils/assistant-surface.js.map +1 -0
  66. package/dist-client/utils/attachment-policy.d.ts +35 -0
  67. package/dist-client/utils/attachment-policy.js +56 -0
  68. package/dist-client/utils/attachment-policy.js.map +1 -0
  69. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  70. package/dist-server/service/assistant-chat-resolver.js +71 -3
  71. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  72. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -0
  73. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +7 -0
  74. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  75. package/dist-server/service/assistant-mode/resolve.d.ts +3 -0
  76. package/dist-server/service/assistant-mode/resolve.js +6 -2
  77. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  78. package/dist-server/service/assistant-mode/types.d.ts +6 -0
  79. package/dist-server/service/assistant-mode/types.js +0 -9
  80. package/dist-server/service/assistant-mode/types.js.map +1 -1
  81. package/dist-server/service/chat-message/chat-message.d.ts +6 -0
  82. package/dist-server/service/chat-message/chat-message.js +19 -0
  83. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  84. package/dist-server/service/chat-session/attachment-policy.d.ts +44 -0
  85. package/dist-server/service/chat-session/attachment-policy.js +56 -0
  86. package/dist-server/service/chat-session/attachment-policy.js.map +1 -0
  87. package/dist-server/service/chat-session/attachment-route.d.ts +3 -0
  88. package/dist-server/service/chat-session/attachment-route.js +44 -0
  89. package/dist-server/service/chat-session/attachment-route.js.map +1 -0
  90. package/dist-server/service/chat-session/attachment-tools.d.ts +3 -0
  91. package/dist-server/service/chat-session/attachment-tools.js +59 -0
  92. package/dist-server/service/chat-session/attachment-tools.js.map +1 -0
  93. package/dist-server/service/chat-session/session-attachments.d.ts +36 -0
  94. package/dist-server/service/chat-session/session-attachments.js +84 -0
  95. package/dist-server/service/chat-session/session-attachments.js.map +1 -0
  96. package/dist-server/service/chat-session/session-inbox.d.ts +29 -0
  97. package/dist-server/service/chat-session/session-inbox.js +32 -8
  98. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  99. package/dist-server/service/chat-session/session-purge.d.ts +14 -0
  100. package/dist-server/service/chat-session/session-purge.js +77 -0
  101. package/dist-server/service/chat-session/session-purge.js.map +1 -0
  102. package/dist-server/service/chat-session/session-retention.d.ts +6 -0
  103. package/dist-server/service/chat-session/session-retention.js +20 -0
  104. package/dist-server/service/chat-session/session-retention.js.map +1 -0
  105. package/dist-server/service/chat-session/session-visibility.d.ts +8 -0
  106. package/dist-server/service/chat-session/session-visibility.js +11 -0
  107. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  108. package/dist-server/service/chat-session/session-write-gate.d.ts +11 -0
  109. package/dist-server/service/chat-session/session-write-gate.js +24 -0
  110. package/dist-server/service/chat-session/session-write-gate.js.map +1 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.d.ts +1 -1
  112. package/dist-server/service/chat-session-participant/invite-notification.js +21 -6
  113. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  114. package/dist-server/service/chat-session-participant/participant-lookup-roles.d.ts +3 -0
  115. package/dist-server/service/chat-session-participant/participant-lookup-roles.js +10 -0
  116. package/dist-server/service/chat-session-participant/participant-lookup-roles.js.map +1 -0
  117. package/dist-server/service/chat-session-participant/participant-lookup.d.ts +2 -0
  118. package/dist-server/service/chat-session-participant/participant-lookup.js +26 -0
  119. package/dist-server/service/chat-session-participant/participant-lookup.js.map +1 -0
  120. package/dist-server/service/chat-session-resolver.d.ts +17 -2
  121. package/dist-server/service/chat-session-resolver.js +135 -15
  122. package/dist-server/service/chat-session-resolver.js.map +1 -1
  123. package/dist-server/service/index.d.ts +5 -0
  124. package/dist-server/service/index.js +5 -0
  125. package/dist-server/service/index.js.map +1 -1
  126. package/dist-server/tsconfig.tsbuildinfo +1 -1
  127. package/package.json +9 -2
  128. package/server/service/assistant-chat-resolver.ts +71 -4
  129. package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
  130. package/server/service/assistant-mode/resolve.ts +9 -2
  131. package/server/service/assistant-mode/types.ts +6 -0
  132. package/server/service/chat-message/chat-message.ts +17 -0
  133. package/server/service/chat-session/attachment-policy.test.ts +42 -0
  134. package/server/service/chat-session/attachment-policy.ts +68 -0
  135. package/server/service/chat-session/attachment-route.ts +45 -0
  136. package/server/service/chat-session/attachment-tools.ts +63 -0
  137. package/server/service/chat-session/session-attachments.ts +89 -0
  138. package/server/service/chat-session/session-inbox.test.ts +71 -1
  139. package/server/service/chat-session/session-inbox.ts +61 -11
  140. package/server/service/chat-session/session-purge.test.ts +24 -0
  141. package/server/service/chat-session/session-purge.ts +81 -0
  142. package/server/service/chat-session/session-retention.ts +17 -0
  143. package/server/service/chat-session/session-visibility.ts +11 -0
  144. package/server/service/chat-session/session-write-gate.test.ts +53 -0
  145. package/server/service/chat-session/session-write-gate.ts +31 -0
  146. package/server/service/chat-session-participant/invite-notification.test.ts +15 -0
  147. package/server/service/chat-session-participant/invite-notification.ts +24 -6
  148. package/server/service/chat-session-participant/participant-lookup-roles.ts +6 -0
  149. package/server/service/chat-session-participant/participant-lookup.ts +22 -0
  150. package/server/service/chat-session-resolver.ts +130 -13
  151. package/server/service/index.ts +5 -0
  152. package/test/assistant-dock.test.ts +124 -0
  153. package/test/assistant-session-manager.test.ts +126 -0
  154. package/test/attachment-policy-agree.test.ts +32 -0
  155. package/test/open-doors.test.ts +5 -2
  156. package/translations/en.json +61 -1
  157. package/translations/ja.json +61 -1
  158. package/translations/ko.json +61 -1
  159. package/translations/ms.json +61 -1
  160. package/translations/zh.json +61 -1
@@ -15,6 +15,8 @@
15
15
  * 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.
16
16
  */
17
17
 
18
+ import { mayReadSession } from './session-visibility.js'
19
+
18
20
  /** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */
19
21
  export interface InboxSessionRow {
20
22
  id?: string
@@ -29,11 +31,15 @@ export interface InboxSessionRow {
29
31
  /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */
30
32
  lastMessageDoors?: string[] | null
31
33
  createdAt?: Date | string | null
34
+ /** 도메인 안 누구나 읽나(`open`) · 참여자만(`private`) — ADR-0073 판정 2. 값이 없으면 open(옛 행). */
35
+ visibility?: string | null
32
36
  }
33
37
 
34
38
  /** 내 참여 기록(읽은 지점). */
35
39
  export interface MyParticipation {
36
40
  sessionId?: string
41
+ /** 이 대화에서 나의 자리 — `owner` 만 공개 범위를 바꾼다. */
42
+ role?: string | null
37
43
  lastReadAt?: Date | string | null
38
44
  /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */
39
45
  hiddenAt?: Date | string | null
@@ -46,6 +52,10 @@ export interface InboxEntry extends InboxSessionRow {
46
52
  participant: boolean
47
53
  /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */
48
54
  hidden?: boolean
55
+ /** `open` · `private` — 목록이 자물쇠를 그리는 근거. */
56
+ visibility: string
57
+ /** 내가 이 대화의 owner 인가 — 공개 범위를 바꿀 수 있는 사람. UI 힌트이고 판정은 문이 다시 한다. */
58
+ owner: boolean
49
59
  }
50
60
 
51
61
  function time(v: Date | string | null | undefined): number {
@@ -68,23 +78,33 @@ function time(v: Date | string | null | undefined): number {
68
78
  export function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[] {
69
79
  const readBy = new Map<string, number>()
70
80
  const isParticipant = new Set<string>()
81
+ const owns = new Set<string>()
71
82
  for (const p of mine) {
72
83
  if (!p.sessionId) continue
73
84
  isParticipant.add(p.sessionId)
74
85
  readBy.set(p.sessionId, time(p.lastReadAt))
86
+ if (p.role === 'owner') owns.add(p.sessionId)
75
87
  }
76
88
 
77
- const entries = sessions.map(session => {
78
- const id = session.id ?? ''
79
- const activity = time(session.lastMessageAt)
80
- const participant = isParticipant.has(id)
81
- const read = readBy.get(id) ?? 0
82
- return {
83
- ...session,
84
- participant,
85
- unread: participant && activity > 0 && activity > read
86
- }
87
- })
89
+ const entries = sessions
90
+ /*
91
+ * 남의 비공개 대화는 목록에도 서지 않는다 — ADR-0073 판정 1 의 「읽는 길은 같은 관문을 지난다」.
92
+ * 메시지 문(`chatMessages`)만 막고 목록이 이름을 내보내면, 대화의 주제가 다른 길로 새어 나간다.
93
+ */
94
+ .filter(session => mayReadSession(session.visibility, isParticipant.has(session.id ?? '')))
95
+ .map(session => {
96
+ const id = session.id ?? ''
97
+ const activity = time(session.lastMessageAt)
98
+ const participant = isParticipant.has(id)
99
+ const read = readBy.get(id) ?? 0
100
+ return {
101
+ ...session,
102
+ participant,
103
+ visibility: session.visibility ?? 'open',
104
+ owner: owns.has(id),
105
+ unread: participant && activity > 0 && activity > read
106
+ }
107
+ })
88
108
 
89
109
  return entries.sort(
90
110
  (a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt))
@@ -121,6 +141,36 @@ export function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date |
121
141
  * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.
122
142
  * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).
123
143
  */
144
+ /** What the conversation list is narrowed by - the manager's filters (design/ai/shared-dock.md §2-6). */
145
+ export interface InboxFilter {
146
+ /** Only this conversation kind (mode). Absent: every kind on the anchor. */
147
+ kind?: string | null
148
+ /** Only the ones I started. */
149
+ mineOnly?: boolean | null
150
+ /** Only the ones I am in (owner or member). */
151
+ participantOnly?: boolean | null
152
+ /** Only 'open' or only 'private'. */
153
+ visibility?: string | null
154
+ /** Only the ones with activity I have not read. */
155
+ unreadOnly?: boolean | null
156
+ }
157
+
158
+ /**
159
+ * Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and
160
+ * this decides only what the person asked to see. A session from before the kind column has no kind:
161
+ * it is shown under every kind, so it is never lost from the list it was started in.
162
+ */
163
+ export function filterInbox<T extends InboxEntry & { mine?: boolean }>(entries: T[], filter: InboxFilter = {}): T[] {
164
+ return entries.filter(entry => {
165
+ if (filter.kind && entry.kind && entry.kind !== filter.kind) return false
166
+ if (filter.mineOnly && !entry.mine) return false
167
+ if (filter.participantOnly && !entry.participant) return false
168
+ if (filter.visibility && (entry.visibility ?? 'open') !== filter.visibility) return false
169
+ if (filter.unreadOnly && !entry.unread) return false
170
+ return true
171
+ })
172
+ }
173
+
124
174
  export function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {
125
175
  const q = (search ?? '').trim().toLowerCase()
126
176
  if (!q) return entries
@@ -0,0 +1,24 @@
1
+ /* When a deleted conversation is emptied - design/ai/shared-dock.md §9-5. */
2
+
3
+ import { DEFAULT_RETENTION_DAYS, purgeCutoff, retentionDaysOf } from './session-retention'
4
+
5
+ describe('retention', () => {
6
+ it('defaults to 30 days, and reads a positive number of days', () => {
7
+ expect(DEFAULT_RETENTION_DAYS).toBe(30)
8
+ expect(retentionDaysOf(undefined)).toBe(30)
9
+ expect(retentionDaysOf('x')).toBe(30)
10
+ expect(retentionDaysOf(7)).toBe(7)
11
+ expect(retentionDaysOf('14')).toBe(14)
12
+ })
13
+
14
+ it('0 or false turns the job off', () => {
15
+ expect(retentionDaysOf(0)).toBeNull()
16
+ expect(retentionDaysOf(false)).toBeNull()
17
+ expect(retentionDaysOf('0')).toBeNull()
18
+ })
19
+
20
+ it('a conversation is due once its deletion is older than the retention', () => {
21
+ const now = new Date('2026-09-25T00:00:00Z')
22
+ expect(purgeCutoff(now, 30).toISOString()).toBe('2026-08-26T00:00:00.000Z')
23
+ })
24
+ })
@@ -0,0 +1,81 @@
1
+ /*
2
+ * Emptying deleted conversations after they have been kept long enough - design/ai/shared-dock.md §9-5.
3
+ *
4
+ * `deleteAISession` is a soft delete: from that moment nobody can open the conversation or its files
5
+ * (every door checks the session), and within the retention period it can still be brought back whole.
6
+ * Nothing ever emptied them afterwards, so a deleted conversation's messages - and now its files - stayed
7
+ * forever (2026-09-25). This job removes a conversation, its messages, its participants and its attached
8
+ * files together once the retention period has passed.
9
+ *
10
+ * Retention: config `ai-assistant.sessionRetentionDays`, default 30. `0` or `false` turns the job off.
11
+ */
12
+ import { config } from '@things-factory/env'
13
+ import { getRepository } from '@things-factory/shell'
14
+ import { In, IsNull, LessThan, Not } from 'typeorm'
15
+
16
+ import { ChatMessage } from '../chat-message/chat-message.js'
17
+ import { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'
18
+ import { ChatSession } from './chat-session.js'
19
+ import { removeSessionFiles } from './session-attachments.js'
20
+
21
+ import { DAY_MS, purgeCutoff, retentionDaysOf } from './session-retention.js'
22
+
23
+ export { DEFAULT_RETENTION_DAYS, purgeCutoff, retentionDaysOf } from './session-retention.js'
24
+ /** How many conversations one run empties - a large backlog is worked off over several runs. */
25
+ export const PURGE_BATCH = 100
26
+
27
+ export interface PurgeResult {
28
+ purged: number
29
+ failed: { sessionId: string; message: string }[]
30
+ }
31
+
32
+ /** Empties the conversations whose deletion is older than the retention period. */
33
+ export async function purgeDeletedSessions(now = new Date(), retentionDays = retentionDaysOf(config.get('ai-assistant', {})?.sessionRetentionDays)): Promise<PurgeResult> {
34
+ const result: PurgeResult = { purged: 0, failed: [] }
35
+ if (retentionDays === null) return result
36
+
37
+ const sessions = await getRepository(ChatSession).find({
38
+ where: { deletedAt: LessThan(purgeCutoff(now, retentionDays)) } as any,
39
+ withDeleted: true,
40
+ relations: ['domain'],
41
+ take: PURGE_BATCH
42
+ })
43
+
44
+ for (const session of sessions) {
45
+ try {
46
+ await removeSessionFiles(session as any)
47
+ /* Messages point at each other (parentMessage) - drop the links before the rows. */
48
+ await getRepository(ChatMessage).update({ session: { id: session.id }, parentMessage: Not(IsNull()) } as any, { parentMessage: null } as any)
49
+ await getRepository(ChatMessage).delete({ session: { id: session.id } } as any)
50
+ await getRepository(ChatSessionParticipant).delete({ session: { id: session.id } } as any)
51
+ /* Board patches and anything else hanging on the session go with it (ON DELETE CASCADE). */
52
+ await getRepository(ChatSession).delete({ id: In([session.id]) } as any)
53
+ result.purged++
54
+ } catch (error) {
55
+ /* One conversation that cannot be emptied must not stop the rest; it is tried again next run. */
56
+ result.failed.push({ sessionId: session.id!, message: (error as Error).message })
57
+ }
58
+ }
59
+
60
+ return result
61
+ }
62
+
63
+ let timer: ReturnType<typeof setInterval> | undefined
64
+
65
+ /** Runs the job once a little after boot, then daily. The timer never keeps the process alive. */
66
+ export function schedulePurge(): void {
67
+ if (timer) return
68
+ const run = () =>
69
+ purgeDeletedSessions()
70
+ .then(({ purged, failed }) => {
71
+ if (purged || failed.length) console.info(`[ai-assistant] purged ${purged} deleted conversation(s)`, failed.length ? { failed } : '')
72
+ })
73
+ .catch(error => console.warn('[ai-assistant] purging deleted conversations failed:', (error as Error).message))
74
+
75
+ const first = setTimeout(run, 60 * 1000)
76
+ first.unref?.()
77
+ timer = setInterval(run, DAY_MS)
78
+ timer.unref?.()
79
+ }
80
+
81
+ process.on('bootstrap-module-start' as any, () => schedulePurge())
@@ -0,0 +1,17 @@
1
+ /* How long a deleted conversation is kept before it is emptied - pure, tested alone (shared-dock.md §9-5). */
2
+
3
+ export const DAY_MS = 24 * 60 * 60 * 1000
4
+ export const DEFAULT_RETENTION_DAYS = 30
5
+
6
+ /** The retention the config asks for: a positive number of days, `null` when the job is off, the default when unreadable. */
7
+ export function retentionDaysOf(value: unknown): number | null {
8
+ if (value === false || value === 0 || value === '0') return null
9
+ const days = Number(value)
10
+ return Number.isFinite(days) && days > 0 ? days : DEFAULT_RETENTION_DAYS
11
+ }
12
+
13
+ /** Conversations deleted before this moment are due. */
14
+ export function purgeCutoff(now: Date, retentionDays: number): Date {
15
+ return new Date(now.getTime() - retentionDays * DAY_MS)
16
+ }
17
+
@@ -32,3 +32,14 @@ export function initialVisibility(session: { anchorType?: string | null; copyOf?
32
32
  export function mayReadSession(visibility: string | null | undefined, isParticipant: boolean): boolean {
33
33
  return (visibility ?? 'open') === 'open' || isParticipant
34
34
  }
35
+
36
+ /**
37
+ * 이 사람이 이 대화에 **쓸** 수 있나 — 읽을 수 있는 사람만 쓴다.
38
+ *
39
+ * 읽는 문(`chatMessages`)과 목록만 막고 쓰는 문을 열어 두면, 비공개 대화의 id 를 가진 사람이 그 안에 말을
40
+ * 넣을 수 있었다. `assistantChat` 에서는 더 나쁘다 — 답이 그 대화의 이력 위에서 만들어지므로, 읽을 수 없는
41
+ * 내용이 답으로 새어 나간다(2026-09-24, operato-ecs 두 사람 시험 7번). 쓰는 문도 같은 관문을 지난다.
42
+ */
43
+ export function mayWriteSession(visibility: string | null | undefined, isParticipant: boolean): boolean {
44
+ return mayReadSession(visibility, isParticipant)
45
+ }
@@ -0,0 +1,53 @@
1
+ /*
2
+ * The write gate - a private conversation is written by its people only (ADR-0073 decision 1, for writing).
3
+ * Reproduces operato-ecs two-person test step 7 (2026-09-24): an outsider wrote into a private conversation
4
+ * by its id, and through assistantChat the answer would be built on that private history.
5
+ */
6
+ import { assertMayWriteSession } from './session-write-gate'
7
+ import { mayWriteSession } from './session-visibility'
8
+ import { isRoomRole } from '../chat-session-participant/participant-lookup-roles'
9
+
10
+ const t = (_key: string, options?: any) => options?.defaultValue ?? _key
11
+
12
+ describe('who may write into a conversation', () => {
13
+ it('anyone in the domain writes into a public one - no lookup', async () => {
14
+ let asked = false
15
+ await expect(
16
+ assertMayWriteSession({ session: { visibility: 'open' }, userId: 'bob', isParticipant: async () => ((asked = true), false), t })
17
+ ).resolves.toBeUndefined()
18
+ expect(asked).toBe(false)
19
+ })
20
+
21
+ it('a row from before the visibility column is public', async () => {
22
+ await expect(assertMayWriteSession({ session: {}, userId: 'bob', isParticipant: async () => false, t })).resolves.toBeUndefined()
23
+ })
24
+
25
+ it('★ an outsider is refused on a private one, by a sentence that says what to do', async () => {
26
+ await expect(
27
+ assertMayWriteSession({ session: { visibility: 'private' }, userId: 'bob', isParticipant: async () => false, t })
28
+ ).rejects.toThrow(/private - only the people in it can write here/)
29
+ })
30
+
31
+ it('a participant writes into a private one', async () => {
32
+ await expect(
33
+ assertMayWriteSession({ session: { visibility: 'private' }, userId: 'alice', isParticipant: async () => true, t })
34
+ ).resolves.toBeUndefined()
35
+ })
36
+
37
+ it('no user is no participant', async () => {
38
+ await expect(assertMayWriteSession({ session: { visibility: 'private' }, isParticipant: async () => true, t })).rejects.toThrow()
39
+ })
40
+
41
+ it('the write rule is the read rule', () => {
42
+ expect(mayWriteSession('open', false)).toBe(true)
43
+ expect(mayWriteSession('private', false)).toBe(false)
44
+ expect(mayWriteSession('private', true)).toBe(true)
45
+ })
46
+
47
+ it('★ a hide-only viewer row is not being in the room', () => {
48
+ expect(isRoomRole('owner')).toBe(true)
49
+ expect(isRoomRole('member')).toBe(true)
50
+ expect(isRoomRole('viewer')).toBe(false)
51
+ expect(isRoomRole(undefined)).toBe(false)
52
+ })
53
+ })
@@ -0,0 +1,31 @@
1
+ /*
2
+ * The gate every door that writes into a conversation passes - ADR-0073 decision 1, applied to writing.
3
+ *
4
+ * `mayWriteSession` is the rule; this looks up only what the rule needs (whether the writer is in the
5
+ * room, and only for a private conversation) and refuses by a named message. Kept apart from the resolvers
6
+ * so the five write doors call one line and cannot drift.
7
+ */
8
+ import { mayWriteSession } from './session-visibility.js'
9
+
10
+ export interface WriteGateInput {
11
+ session: { id?: string; visibility?: string | null }
12
+ userId?: string
13
+ /** Is this user a participant of the session (owner or member)? Asked only for a private one. */
14
+ isParticipant: () => Promise<boolean>
15
+ t: (key: string, options?: Record<string, unknown>) => string
16
+ }
17
+
18
+ export async function assertMayWriteSession({ session, userId, isParticipant, t }: WriteGateInput): Promise<void> {
19
+ const visibility = session.visibility ?? 'open'
20
+ /* A public conversation is open to everyone in the domain - no lookup. */
21
+ if (visibility === 'open') return
22
+
23
+ const participant = !!userId && (await isParticipant())
24
+ if (!mayWriteSession(visibility, participant)) {
25
+ throw new Error(
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.'
28
+ })
29
+ )
30
+ }
31
+ }
@@ -28,3 +28,18 @@ describe('buildInviteNotification — 협업 초대 알림 payload', () => {
28
28
  expect(buildInviteNotification(['u1'], undefined, { name: 'Kim' })!.url).toBeUndefined()
29
29
  })
30
30
  })
31
+
32
+ describe('an invite outside a board', () => {
33
+ it('does not call it a board', () => {
34
+ const p = buildInviteNotification(['u1'], undefined, { name: 'Kim' }, 'press-plc down')!
35
+ expect(p.type).toBe('chat-invite')
36
+ expect(p.title).toBe('Conversation invite')
37
+ expect(p.title).not.toMatch(/board/i)
38
+ expect(p.body).toContain('press-plc down')
39
+ expect(p.url).toBeUndefined()
40
+ })
41
+
42
+ it('says a conversation when it has no name yet', () => {
43
+ expect(buildInviteNotification(['u1'], undefined, { name: 'Kim' })!.body).toBe('Kim brought you into a conversation.')
44
+ })
45
+ })
@@ -30,19 +30,37 @@ export interface Inviter {
30
30
  export function buildInviteNotification(
31
31
  userIds: string[],
32
32
  boardId: string | undefined,
33
- inviter: Inviter | undefined | null
33
+ inviter: Inviter | undefined | null,
34
+ sessionName?: string | null
34
35
  ): InviteNotification | null {
35
36
  if (!Array.isArray(userIds) || userIds.length === 0) return null
36
37
 
37
38
  const who = inviter?.name || inviter?.email || 'someone'
39
+
40
+ /* A board conversation keeps its board wording and the link to the modeller. */
41
+ if (boardId) {
42
+ return {
43
+ receivers: userIds,
44
+ type: 'board-collab-invite',
45
+ subject: 'info',
46
+ title: 'Board collaboration invite',
47
+ body: `${who} invited you to collaborate on a board.`,
48
+ url: `board-modeller/${boardId}`,
49
+ mode: 'background'
50
+ }
51
+ }
52
+
53
+ /*
54
+ * Every other conversation is not about a board. Both chat doors called this without one and every
55
+ * invite read "Board collaboration invite" - in operato-ecs, in plant, anywhere (2026-09-24).
56
+ */
57
+ const name = sessionName?.trim()
38
58
  return {
39
59
  receivers: userIds,
40
- type: 'board-collab-invite',
60
+ type: 'chat-invite',
41
61
  subject: 'info',
42
- title: 'Board collaboration invite',
43
- body: `${who} invited you to collaborate on a board.`,
44
- // board 가 연결돼 있으면 모델러로 바로 가는 링크. 없으면 링크 생략.
45
- url: boardId ? `board-modeller/${boardId}` : undefined,
62
+ title: 'Conversation invite',
63
+ body: name ? `${who} brought you into the conversation "${name}".` : `${who} brought you into a conversation.`,
46
64
  mode: 'background'
47
65
  }
48
66
  }
@@ -0,0 +1,6 @@
1
+ /** Being in the room - owner or member. A `viewer` row only remembers a hide. Pure, so it is tested alone. */
2
+ export const ROOM_ROLES = ['owner', 'member'] as const
3
+
4
+ export function isRoomRole(role: string | null | undefined): boolean {
5
+ return (ROOM_ROLES as readonly string[]).includes(role ?? '')
6
+ }
@@ -0,0 +1,22 @@
1
+ /*
2
+ * Is this user in the room - owner or member?
3
+ *
4
+ * A `viewer` row is not being in the room: `hideAISession` writes one to remember a hide for someone who
5
+ * never took part. Counting it let a person who hid a private conversation by its id read it afterwards -
6
+ * the hide became the key. Every gate that asks "is this a participant" asks here.
7
+ */
8
+ import { getRepository } from '@things-factory/shell'
9
+
10
+ import { ChatSessionParticipant } from './chat-session-participant.js'
11
+
12
+ import { isRoomRole } from './participant-lookup-roles.js'
13
+
14
+ export { ROOM_ROLES, isRoomRole } from './participant-lookup-roles.js'
15
+
16
+ export async function isSessionParticipant(domainId: string, sessionId: string, userId: string | undefined, tx?: any): Promise<boolean> {
17
+ if (!userId) return false
18
+ const row = await getRepository(ChatSessionParticipant, tx).findOne({
19
+ where: { domain: { id: domainId } as any, session: { id: sessionId } as any, user: { id: userId } as any }
20
+ })
21
+ return isRoomRole(row?.role)
22
+ }