@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.
- package/client/components/assistant-chat.ts +238 -9
- package/client/components/assistant-dock.ts +9 -4
- package/client/components/assistant-invite.ts +138 -0
- package/client/components/assistant-session-manager.ts +53 -12
- package/client/components/proposal-request.test.ts +10 -0
- package/client/components/roster-identity.test.ts +2 -12
- package/client/components/roster-identity.ts +0 -6
- package/client/setup-assistant-dock.ts +1 -0
- package/client/utils/assistant-session-actions.ts +3 -1
- package/client/utils/assistant-session-admin.ts +9 -2
- package/client/utils/assistant-session-controller.ts +1 -1
- package/client/utils/assistant-typing.test.ts +33 -0
- package/client/utils/assistant-typing.ts +53 -0
- package/dist-client/components/assistant-chat.d.ts +28 -0
- package/dist-client/components/assistant-chat.js +259 -8
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.js +12 -4
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-invite.d.ts +39 -0
- package/dist-client/components/assistant-invite.js +150 -0
- package/dist-client/components/assistant-invite.js.map +1 -0
- package/dist-client/components/assistant-session-manager.d.ts +5 -1
- package/dist-client/components/assistant-session-manager.js +40 -10
- package/dist-client/components/assistant-session-manager.js.map +1 -1
- package/dist-client/components/proposal-request.test.js +8 -0
- package/dist-client/components/proposal-request.test.js.map +1 -1
- package/dist-client/components/roster-identity.d.ts +0 -2
- package/dist-client/components/roster-identity.js +0 -7
- package/dist-client/components/roster-identity.js.map +1 -1
- package/dist-client/components/roster-identity.test.js +2 -13
- package/dist-client/components/roster-identity.test.js.map +1 -1
- package/dist-client/setup-assistant-dock.js +1 -0
- package/dist-client/setup-assistant-dock.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-session-actions.d.ts +1 -0
- package/dist-client/utils/assistant-session-actions.js +3 -1
- package/dist-client/utils/assistant-session-actions.js.map +1 -1
- package/dist-client/utils/assistant-session-admin.d.ts +5 -2
- package/dist-client/utils/assistant-session-admin.js +3 -0
- package/dist-client/utils/assistant-session-admin.js.map +1 -1
- package/dist-client/utils/assistant-session-controller.d.ts +1 -1
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-typing.d.ts +20 -0
- package/dist-client/utils/assistant-typing.js +48 -0
- package/dist-client/utils/assistant-typing.js.map +1 -0
- package/dist-client/utils/assistant-typing.test.d.ts +1 -0
- package/dist-client/utils/assistant-typing.test.js +30 -0
- package/dist-client/utils/assistant-typing.test.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.js +5 -12
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +1 -1
- package/dist-server/service/assistant-mode/index.js +4 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.js +9 -2
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/assistant-mode/screen-rules.d.ts +19 -0
- package/dist-server/service/assistant-mode/screen-rules.js +40 -6
- package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
- package/dist-server/service/chat-message/chat-message-room.js +3 -1
- package/dist-server/service/chat-message/chat-message-room.js.map +1 -1
- package/dist-server/service/chat-message/chat-typing-subscription.d.ts +10 -0
- package/dist-server/service/chat-message/chat-typing-subscription.js +68 -0
- package/dist-server/service/chat-message/chat-typing-subscription.js.map +1 -0
- package/dist-server/service/chat-message/chat-typing.d.ts +28 -0
- package/dist-server/service/chat-message/chat-typing.js +40 -0
- package/dist-server/service/chat-message/chat-typing.js.map +1 -0
- package/dist-server/service/chat-session/attachment-tool-result.d.ts +1 -0
- package/dist-server/service/chat-session/attachment-tool-result.js +6 -0
- package/dist-server/service/chat-session/attachment-tool-result.js.map +1 -1
- package/dist-server/service/chat-session/attachment-tools.js +2 -2
- package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.d.ts +2 -1
- package/dist-server/service/chat-session/chat-session.js +1 -1
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-activity-broadcast.d.ts +12 -0
- package/dist-server/service/chat-session/session-activity-broadcast.js +15 -0
- package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -1
- package/dist-server/service/chat-session/session-activity-publish.d.ts +2 -0
- package/dist-server/service/chat-session/session-activity-publish.js +5 -2
- package/dist-server/service/chat-session/session-activity-publish.js.map +1 -1
- package/dist-server/service/chat-session/session-visibility.d.ts +17 -2
- package/dist-server/service/chat-session/session-visibility.js +28 -3
- package/dist-server/service/chat-session/session-visibility.js.map +1 -1
- package/dist-server/service/chat-session/session-write-gate.d.ts +1 -1
- package/dist-server/service/chat-session/session-write-gate.js +1 -1
- package/dist-server/service/chat-session/session-write-gate.js.map +1 -1
- package/dist-server/service/chat-session-participant/index.d.ts +1 -0
- package/dist-server/service/chat-session-participant/index.js +4 -1
- package/dist-server/service/chat-session-participant/index.js.map +1 -1
- package/dist-server/service/chat-session-participant/invite-notification.d.ts +16 -1
- package/dist-server/service/chat-session-participant/invite-notification.js +34 -5
- package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
- package/dist-server/service/chat-session-participant/mention-invite.d.ts +30 -4
- package/dist-server/service/chat-session-participant/mention-invite.js +78 -31
- package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -1
- package/dist-server/service/chat-session-participant/read-roster.js +5 -2
- package/dist-server/service/chat-session-participant/read-roster.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +27 -0
- package/dist-server/service/chat-session-resolver.js +226 -25
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/data-entry/entry-target.d.ts +19 -0
- package/dist-server/service/data-entry/entry-target.js +20 -5
- package/dist-server/service/data-entry/entry-target.js.map +1 -1
- package/dist-server/service/data-entry/entry-tools.js +2 -2
- package/dist-server/service/data-entry/entry-tools.js.map +1 -1
- package/dist-server/service/data-entry/entry-update.d.ts +2 -1
- package/dist-server/service/data-entry/entry-update.js +5 -3
- package/dist-server/service/data-entry/entry-update.js.map +1 -1
- package/dist-server/service/data-read/list-read.d.ts +10 -0
- package/dist-server/service/data-read/list-read.js +12 -0
- package/dist-server/service/data-read/list-read.js.map +1 -1
- package/dist-server/service/data-read/read-target.d.ts +7 -0
- package/dist-server/service/data-read/read-target.js +2 -2
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/data-read/read-tools.js +1 -1
- package/dist-server/service/data-read/read-tools.js.map +1 -1
- package/dist-server/service/index.d.ts +4 -2
- package/dist-server/service/index.js +9 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/testing/conversation-script.d.ts +1 -1
- package/dist-server/service/testing/conversation-script.js.map +1 -1
- package/dist-server/service/testing/mode-conformance.js +4 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/server/explicit-graphql-types.test.ts +1 -1
- package/server/service/assistant-chat-resolver.ts +7 -12
- package/server/service/assistant-mode/index.ts +1 -1
- package/server/service/assistant-mode/mode-categories.ts +11 -3
- package/server/service/assistant-mode/mode-front.test.ts +53 -1
- package/server/service/assistant-mode/resolve.test.ts +2 -1
- package/server/service/assistant-mode/screen-rules.test.ts +41 -3
- package/server/service/assistant-mode/screen-rules.ts +44 -5
- package/server/service/chat-message/chat-message-room.test.ts +7 -2
- package/server/service/chat-message/chat-message-room.ts +3 -1
- package/server/service/chat-message/chat-typing-subscription.ts +52 -0
- package/server/service/chat-message/chat-typing.test.ts +45 -0
- package/server/service/chat-message/chat-typing.ts +48 -0
- package/server/service/chat-message/fold-history-window.test.ts +1 -1
- package/server/service/chat-session/attachment-tool-result.test.ts +9 -1
- package/server/service/chat-session/attachment-tool-result.ts +7 -0
- package/server/service/chat-session/attachment-tools.ts +3 -3
- package/server/service/chat-session/chat-session.ts +3 -2
- package/server/service/chat-session/group-room.test.ts +233 -0
- package/server/service/chat-session/session-activity-broadcast.ts +20 -0
- package/server/service/chat-session/session-activity-publish.ts +6 -1
- package/server/service/chat-session/session-activity-receivers.test.ts +26 -0
- package/server/service/chat-session/session-visibility.test.ts +32 -1
- package/server/service/chat-session/session-visibility.ts +33 -4
- package/server/service/chat-session/session-write-gate.test.ts +7 -1
- package/server/service/chat-session/session-write-gate.ts +2 -2
- package/server/service/chat-session-participant/index.ts +2 -0
- package/server/service/chat-session-participant/invite-notification.test.ts +36 -1
- package/server/service/chat-session-participant/invite-notification.ts +43 -5
- package/server/service/chat-session-participant/mention-invite.test.ts +49 -1
- package/server/service/chat-session-participant/mention-invite.ts +82 -28
- package/server/service/chat-session-participant/read-roster.test.ts +13 -4
- package/server/service/chat-session-participant/read-roster.ts +6 -3
- package/server/service/chat-session-resolver.ts +204 -27
- package/server/service/data-entry/data-entry-update.test.ts +19 -0
- package/server/service/data-entry/data-entry.test.ts +65 -1
- package/server/service/data-entry/entry-target.ts +36 -5
- package/server/service/data-entry/entry-tools.ts +2 -2
- package/server/service/data-entry/entry-update.ts +6 -3
- package/server/service/data-read/list-missing.test.ts +49 -0
- package/server/service/data-read/list-read.ts +18 -0
- package/server/service/data-read/read-target.ts +10 -3
- package/server/service/data-read/read-tools.ts +1 -1
- package/server/service/data-read/record-keys.test.ts +36 -0
- package/server/service/index.ts +6 -1
- package/server/service/testing/conversation-script.ts +1 -1
- package/server/service/testing/mode-conformance.ts +2 -0
- package/test/assistant-dock.test.ts +5 -1
- package/test/assistant-session-manager.test.ts +4 -2
- package/test/open-doors.test.ts +10 -5
- package/translations/en.json +29 -4
- package/translations/es.json +29 -4
- package/translations/ja.json +29 -4
- package/translations/ko.json +30 -5
- package/translations/ms.json +29 -4
- package/translations/zh.json +29 -4
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Until this moved out of the board product, `@name` in a twin deliberation added nobody at all,
|
|
6
6
|
* and the person who typed it had every reason to think they had called a colleague in.
|
|
7
7
|
*/
|
|
8
|
-
import { extractMentionUserIds, inviteMentionedUsers, newParticipantUserIds } from './mention-invite'
|
|
8
|
+
import { extractMentionUserIds, inviteMentionedUsers, newParticipantUserIds, seatTurn, seatWriter } from './mention-invite'
|
|
9
9
|
|
|
10
10
|
const marker = (id: string) => `@someone{userId:${id}}`
|
|
11
11
|
|
|
@@ -123,3 +123,51 @@ describe('the pure parts', () => {
|
|
|
123
123
|
expect(newParticipantUserIds(['a', 'b', 'me'], ['b'], 'me')).toEqual(['a'])
|
|
124
124
|
})
|
|
125
125
|
})
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* The writer's seat (2026-10-24): four people wrote in an open conversation and its roster showed two - speaking
|
|
129
|
+
* seated nobody, so the two who walked in had no chip, no presence and no unread badge.
|
|
130
|
+
*/
|
|
131
|
+
describe('the person who spoke', () => {
|
|
132
|
+
const room = (rows: any[], visibility = 'open') => {
|
|
133
|
+
const p = participants(rows)
|
|
134
|
+
return { ...p, input: { sessionId: 's1', domainId: 'd1', inviter: { id: 'walker' }, participants: p.repo, visibility } }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
it('★ someone who walks into an open room and speaks is seated as a member', async () => {
|
|
138
|
+
const { input, saved } = room([owner])
|
|
139
|
+
expect(await seatWriter(input)).toBe(true)
|
|
140
|
+
expect(saved).toEqual([expect.objectContaining({ user: { id: 'walker' }, role: 'member' })])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('★ the turn seats the writer and the people mentioned together', async () => {
|
|
144
|
+
const { input, saved } = room([owner])
|
|
145
|
+
await seatTurn({ ...input, content: `ask ${marker('kim')}` })
|
|
146
|
+
expect(saved.map(row => row.user.id)).toEqual(['walker', 'kim'])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('someone seated already is left as they are', async () => {
|
|
150
|
+
const { input, saved, updated } = room([owner, { id: 'p2', userId: 'walker', role: 'member' }])
|
|
151
|
+
expect(await seatWriter(input)).toBe(false)
|
|
152
|
+
expect(saved).toEqual([])
|
|
153
|
+
expect(updated).toEqual([])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('who hid it or left it comes back on the same row', async () => {
|
|
157
|
+
const hid = room([owner, { id: 'p2', userId: 'walker', role: 'viewer' }])
|
|
158
|
+
await seatWriter(hid.input)
|
|
159
|
+
expect(hid.updated).toEqual([{ id: 'p2', patch: expect.objectContaining({ role: 'member', deletedAt: null }) }])
|
|
160
|
+
const left = room([owner, { id: 'p3', userId: 'walker', role: 'member', deletedAt: new Date() }])
|
|
161
|
+
await seatWriter(left.input)
|
|
162
|
+
expect(left.updated).toEqual([{ id: 'p3', patch: expect.objectContaining({ role: 'member', deletedAt: null }) }])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('★ a group or private room is not seated by speaking - there the seat is the key', async () => {
|
|
166
|
+
for (const visibility of ['group', 'private']) {
|
|
167
|
+
const { input, saved, updated } = room([owner], visibility)
|
|
168
|
+
expect(await seatWriter(input)).toBe(false)
|
|
169
|
+
expect(saved).toEqual([])
|
|
170
|
+
expect(updated).toEqual([])
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
})
|
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
* What went: the inviter's own role (there is nothing to hand out), the refusal verdicts, and the
|
|
20
20
|
* notice shown before sending. A private conversation is the one place a mention cannot seat
|
|
21
21
|
* someone — there the named person is told and stays outside until an owner opens it.
|
|
22
|
+
*
|
|
23
|
+
* ── A group (2026-10-24, ADR-0073 decision 3) ──────────────────────────────────
|
|
24
|
+
* In a group the seat is the key, so who seats is asked again: the owner's mention seats, a member's
|
|
25
|
+
* tells (`maySeat`). The invite door (`inviteToAISession`) seats through the same `seatParticipants`
|
|
26
|
+
* under the same rule - two ways in, one seat.
|
|
22
27
|
*/
|
|
28
|
+
import { isOpenRoom, maySeat } from '../chat-session/session-visibility.js'
|
|
23
29
|
|
|
24
30
|
/** 본문에서 `@user{userId:UUID}` 마커의 userId 를 뽑는다 — 첫 등장 순, 중복 제거. */
|
|
25
31
|
export function extractMentionUserIds(text: string | null | undefined): string[] {
|
|
@@ -59,7 +65,7 @@ export function newParticipantUserIds(
|
|
|
59
65
|
export interface MentionInviteResult {
|
|
60
66
|
/** 이번에 새로 앉은 사람들. 알림은 이들에게 간다. */
|
|
61
67
|
invited: string[]
|
|
62
|
-
/**
|
|
68
|
+
/** 앉힐 수 없는 방이거나 부른 사람에게 그 자리가 없어 앉히지 못한 사람들 — 이름은 불렸고 방에는 못 들어온다. */
|
|
63
69
|
notSeated?: string[]
|
|
64
70
|
}
|
|
65
71
|
|
|
@@ -71,51 +77,99 @@ export interface MentionInviteInput {
|
|
|
71
77
|
inviter: { id?: string } | undefined | null
|
|
72
78
|
/** 참여자 저장소 — 시험이 가짜를 넣는다. */
|
|
73
79
|
participants: any
|
|
74
|
-
/** 이
|
|
80
|
+
/** 이 대화의 공개 범위(ADR-0073). 그룹은 owner 의 멘션만, 비공개는 아무의 멘션도 앉히지 않는다. */
|
|
75
81
|
visibility?: string | null
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
/**
|
|
79
|
-
* 멘션된 사람을 참여자로 앉힌다.
|
|
80
|
-
*
|
|
81
|
-
* 나갔던 사람(soft delete)은 되살린다 — 다시 부른 것이지 새 사람이 아니다.
|
|
82
|
-
*/
|
|
84
|
+
/** 멘션된 사람을 참여자로 앉힌다 — 앉힐 수 있는 방이고 부른 사람에게 그 자리가 있을 때만(`maySeat`). */
|
|
83
85
|
export async function inviteMentionedUsers(input: MentionInviteInput): Promise<MentionInviteResult> {
|
|
84
86
|
const mentioned = extractMentionUserIds(input.content)
|
|
85
87
|
if (mentioned.length === 0) return { invited: [] }
|
|
88
|
+
return seatParticipants({ ...input, userIds: mentioned })
|
|
89
|
+
}
|
|
86
90
|
|
|
91
|
+
export interface SeatInput {
|
|
92
|
+
sessionId: string
|
|
93
|
+
domainId: string
|
|
94
|
+
inviter: { id?: string } | undefined | null
|
|
95
|
+
/** 앉힐 사람들. 이미 앉은 사람과 부른 사람 자신은 빠진다. */
|
|
96
|
+
userIds: ReadonlyArray<string>
|
|
97
|
+
participants: any
|
|
98
|
+
visibility?: string | null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 사람을 앉힌다 — 멘션과 초대 단추가 함께 쓰는 한 곳.
|
|
103
|
+
*
|
|
104
|
+
* 규칙은 `maySeat` 한 줄이다: 공개 방은 누구나, 그룹은 owner 만, 비공개는 아무도. 앉히지 못하면
|
|
105
|
+
* 아무것도 쓰지 않고 그 사람들을 `notSeated` 로 돌려준다 — 화면이 그대로 말한다.
|
|
106
|
+
* 나갔던 사람(soft delete)은 되살린다 — 다시 부른 것이지 새 사람이 아니다.
|
|
107
|
+
*/
|
|
108
|
+
export async function seatParticipants(input: SeatInput): Promise<MentionInviteResult> {
|
|
87
109
|
const repo = input.participants
|
|
88
110
|
const existing = await repo.find({
|
|
89
111
|
where: { session: { id: input.sessionId } as any, domain: { id: input.domainId } as any },
|
|
90
112
|
withDeleted: true
|
|
91
113
|
})
|
|
92
114
|
|
|
93
|
-
|
|
94
|
-
const
|
|
115
|
+
/* A hide-only `viewer` row is not a seat - the person is brought in over it, as a member. */
|
|
116
|
+
const seated = existing.filter((row: any) => !row.deletedAt && row.role !== 'viewer')
|
|
117
|
+
const activeIds = seated.map((row: any) => row.userId).filter(Boolean)
|
|
118
|
+
const fresh = newParticipantUserIds(input.userIds, activeIds, input.inviter?.id)
|
|
95
119
|
if (fresh.length === 0) return { invited: [] }
|
|
96
120
|
|
|
97
121
|
/*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* 일이다 — 이름이 불렸다는 것만 알린다.
|
|
122
|
+
* 공개 방에서 앉히는 일은 아무것도 주지 않지만(누구나 들어온다), 그룹과 비공개에서는 그것이 곧 접근을
|
|
123
|
+
* 주는 일이다. 그룹에서는 owner 가 주고, 비공개에서는 아무도 주지 않는다 — 이름이 불렸다는 것만 남는다.
|
|
101
124
|
*/
|
|
102
|
-
|
|
125
|
+
const inviterRole = seated.find((row: any) => row.userId === input.inviter?.id)?.role
|
|
126
|
+
if (!maySeat(input.visibility, inviterRole)) return { invited: [], notSeated: fresh }
|
|
103
127
|
|
|
104
|
-
for (const userId of fresh)
|
|
105
|
-
const prior = existing.find((row: any) => row.userId === userId && row.deletedAt)
|
|
106
|
-
if (prior) {
|
|
107
|
-
await repo.update(prior.id, { deletedAt: null, role: 'member', updater: input.inviter } as any)
|
|
108
|
-
} else {
|
|
109
|
-
await repo.save({
|
|
110
|
-
domain: { id: input.domainId } as any,
|
|
111
|
-
session: { id: input.sessionId } as any,
|
|
112
|
-
user: { id: userId } as any,
|
|
113
|
-
role: 'member',
|
|
114
|
-
creator: input.inviter,
|
|
115
|
-
updater: input.inviter
|
|
116
|
-
} as any)
|
|
117
|
-
}
|
|
118
|
-
}
|
|
128
|
+
for (const userId of fresh) await writeMemberSeat(repo, existing, input, userId)
|
|
119
129
|
|
|
120
130
|
return { invited: fresh }
|
|
121
131
|
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The seats a person's turn gives - what every door that stores a person's words calls.
|
|
135
|
+
*
|
|
136
|
+
* The writer first: in an open room anyone may walk in and speak, and until 2026-10-24 speaking seated nobody. A
|
|
137
|
+
* conversation four people wrote in showed two in its roster - the one who started it and the one who was
|
|
138
|
+
* mentioned - and the other two had no presence, no unread badge and no activity notice. In a group or private
|
|
139
|
+
* room the writer is seated already (the write gate let them in by their seat). Then the people mentioned.
|
|
140
|
+
*/
|
|
141
|
+
export async function seatTurn(input: MentionInviteInput): Promise<MentionInviteResult> {
|
|
142
|
+
await seatWriter(input)
|
|
143
|
+
return inviteMentionedUsers(input)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Seat the person who spoke, in an open room only - there the seat grants nothing, it records who took part. */
|
|
147
|
+
export async function seatWriter(input: Omit<SeatInput, 'userIds'>): Promise<boolean> {
|
|
148
|
+
const writerId = input.inviter?.id
|
|
149
|
+
if (!writerId || !isOpenRoom(input.visibility)) return false
|
|
150
|
+
const repo = input.participants
|
|
151
|
+
const existing = await repo.find({
|
|
152
|
+
where: { session: { id: input.sessionId } as any, domain: { id: input.domainId } as any },
|
|
153
|
+
withDeleted: true
|
|
154
|
+
})
|
|
155
|
+
if (existing.some((row: any) => row.userId === writerId && !row.deletedAt && row.role !== 'viewer')) return false
|
|
156
|
+
await writeMemberSeat(repo, existing, input, writerId)
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One member seat: someone who left (soft delete) or only hid it (`viewer`) is brought back on that row, never a second one. */
|
|
161
|
+
async function writeMemberSeat(repo: any, existing: any[], input: { sessionId: string; domainId: string; inviter: { id?: string } | undefined | null }, userId: string) {
|
|
162
|
+
const prior = existing.find((row: any) => row.userId === userId && (row.deletedAt || row.role === 'viewer'))
|
|
163
|
+
if (prior) {
|
|
164
|
+
await repo.update(prior.id, { deletedAt: null, role: 'member', updater: input.inviter } as any)
|
|
165
|
+
} else {
|
|
166
|
+
await repo.save({
|
|
167
|
+
domain: { id: input.domainId } as any,
|
|
168
|
+
session: { id: input.sessionId } as any,
|
|
169
|
+
user: { id: userId } as any,
|
|
170
|
+
role: 'member',
|
|
171
|
+
creator: input.inviter,
|
|
172
|
+
updater: input.inviter
|
|
173
|
+
} as any)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -2,17 +2,26 @@ import { readRoster } from './read-roster'
|
|
|
2
2
|
|
|
3
3
|
describe('participant roster identities', () => {
|
|
4
4
|
it('loads the participating user relation for public conversations', async () => {
|
|
5
|
-
const find = jest.fn(async options => options.relations.includes('user') ? [{id:'p',user:{id:'u',name:'김희태'}}] : [{id:'p'}])
|
|
5
|
+
const find = jest.fn(async options => options.relations.includes('user') ? [{id:'p',role:'owner',user:{id:'u',name:'김희태'}}] : [{id:'p',role:'owner'}])
|
|
6
6
|
const isParticipant=jest.fn(async()=>false)
|
|
7
7
|
const rows=await readRoster({id:'room',domainId:'domain',visibility:'open'},{find,isParticipant})
|
|
8
|
-
expect(rows).toEqual([{id:'p',user:{id:'u',name:'김희태'}}])
|
|
8
|
+
expect(rows).toEqual([{id:'p',role:'owner',user:{id:'u',name:'김희태'}}])
|
|
9
9
|
expect(find).toHaveBeenCalledWith(expect.objectContaining({where:{session:{id:'room'},domain:{id:'domain'}},relations:['user']}))
|
|
10
10
|
expect(isParticipant).not.toHaveBeenCalled()
|
|
11
11
|
})
|
|
12
12
|
it('does not expose identities from a private room to an outsider', async () => {
|
|
13
|
-
const find=jest.fn(async()=>[{id:'p',user:{name:'Private'}}])
|
|
13
|
+
const find=jest.fn(async()=>[{id:'p',role:'owner',user:{name:'Private'}}])
|
|
14
14
|
expect(await readRoster({id:'r',domainId:'d',visibility:'private'},{find,isParticipant:async()=>false})).toEqual([])
|
|
15
15
|
expect(find).not.toHaveBeenCalled()
|
|
16
|
-
expect(await readRoster({id:'r',domainId:'d',visibility:'private'},{find,isParticipant:async()=>true})).toEqual([{id:'p',user:{name:'Private'}}])
|
|
16
|
+
expect(await readRoster({id:'r',domainId:'d',visibility:'private'},{find,isParticipant:async()=>true})).toEqual([{id:'p',role:'owner',user:{name:'Private'}}])
|
|
17
|
+
})
|
|
18
|
+
it('★ a group room is closed to an outsider too - it is not a public room', async () => {
|
|
19
|
+
const find=jest.fn(async()=>[{id:'p',role:'owner',user:{name:'Group'}}])
|
|
20
|
+
expect(await readRoster({id:'r',domainId:'d',visibility:'group'},{find,isParticipant:async()=>false})).toEqual([])
|
|
21
|
+
expect(find).not.toHaveBeenCalled()
|
|
22
|
+
})
|
|
23
|
+
it('lists the seats - a hide-only viewer row is not someone in the room', async () => {
|
|
24
|
+
const find=jest.fn(async()=>[{id:'p',role:'owner'},{id:'q',role:'member'},{id:'v',role:'viewer'}])
|
|
25
|
+
expect((await readRoster({id:'r',domainId:'d',visibility:'open'},{find,isParticipant:async()=>false})).map((row:any)=>row.id)).toEqual(['p','q'])
|
|
17
26
|
})
|
|
18
27
|
})
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { mayReadSession } from '../chat-session/session-visibility.js'
|
|
1
|
+
import { isOpenRoom, mayReadSession } from '../chat-session/session-visibility.js'
|
|
2
|
+
import { isRoomRole } from './participant-lookup-roles.js'
|
|
2
3
|
|
|
3
4
|
/** Read the room roster with user identities hydrated, after applying the room's visibility gate. */
|
|
4
5
|
export async function readRoster<T>(
|
|
5
6
|
room: { id: string; domainId: string; visibility?: string },
|
|
6
7
|
port: { isParticipant(): Promise<boolean>; find(options: any): Promise<T[]> }
|
|
7
8
|
): Promise<T[]> {
|
|
8
|
-
if (!mayReadSession(room.visibility, room.visibility
|
|
9
|
-
|
|
9
|
+
if (!mayReadSession(room.visibility, isOpenRoom(room.visibility) ? false : await port.isParticipant())) return []
|
|
10
|
+
/* A hide-only `viewer` row is not someone in the room - the roster lists the seats. */
|
|
11
|
+
const rows = await port.find({
|
|
10
12
|
where: { session: { id: room.id }, domain: { id: room.domainId } },
|
|
11
13
|
relations: ['user'],
|
|
12
14
|
order: { createdAt: 'ASC' }
|
|
13
15
|
})
|
|
16
|
+
return rows.filter((row: any) => isRoomRole(row?.role))
|
|
14
17
|
}
|
|
@@ -9,7 +9,7 @@ import { GraphQLJSON } from 'graphql-scalars'
|
|
|
9
9
|
import { User } from '@things-factory/auth-base'
|
|
10
10
|
import { buildDomainUsersQueryBuilder } from '@things-factory/auth-base/dist-server/utils/get-domain-users.js'
|
|
11
11
|
|
|
12
|
-
import { getRepository } from '@things-factory/shell'
|
|
12
|
+
import { getRepository, pubsub } from '@things-factory/shell'
|
|
13
13
|
import { In, Like } from 'typeorm'
|
|
14
14
|
|
|
15
15
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
@@ -18,28 +18,29 @@ import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, searc
|
|
|
18
18
|
import { carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './chat-message/carried-turns.js'
|
|
19
19
|
import { roomMessageBody } from './chat-message/room-message.js'
|
|
20
20
|
import { readerGate } from './chat-message/reader-gate.js'
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
21
|
+
import { requestTranslator } from './request-translator.js'
|
|
22
|
+
import { initialVisibility, isOpenRoom, mayReadSession, maySeat, SESSION_VISIBILITIES } from './chat-session/session-visibility.js'
|
|
23
|
+
import { seatParticipants, seatTurn } from './chat-session-participant/mention-invite.js'
|
|
24
|
+
import { CHAT_TYPING_TOPIC, typingNotice } from './chat-message/chat-typing.js'
|
|
25
|
+
import { sendInviteNotification } from './chat-session-participant/invite-notification.js'
|
|
24
26
|
import { readRoster } from './chat-session-participant/read-roster.js'
|
|
25
27
|
import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
|
|
26
28
|
import { assertMayWriteSession } from './chat-session/session-write-gate.js'
|
|
27
29
|
|
|
30
|
+
/** 초대 알림 — 발송은 호스트의 기전을 그대로 쓴다(`sendInviteNotification`, 문 셋이 같은 한 곳). */
|
|
31
|
+
async function notifyInvited(context: any, invited: string[], inviter: any, sessionName?: string): Promise<void> {
|
|
32
|
+
await sendInviteNotification(context.state?.notify, invited, inviter, sessionName, undefined, requestTranslator(context))
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* 알림이 안 가도 초대 자체는 이미 일어났다. 그래서 실패를 삼키되, 초대를 되돌리지는 않는다 —
|
|
32
|
-
* 참여자 목록이 정본이고 알림은 그것을 알리는 편지다.
|
|
36
|
+
* A private room is the owner's alone (ADR-0073 decision 3) - closing a room to private sends its members out.
|
|
37
|
+
* Returns who left, so their lists can be told once.
|
|
33
38
|
*/
|
|
34
|
-
async function
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
await context.state?.notify?.(payload)
|
|
40
|
-
} catch {
|
|
41
|
-
/* 편지가 못 갔다고 방에 들어온 사실이 취소되지는 않는다. */
|
|
42
|
-
}
|
|
39
|
+
async function sendOutMembers(domainId: string, sessionId: string, tx?: any): Promise<string[]> {
|
|
40
|
+
const repo = getRepository(ChatSessionParticipant, tx)
|
|
41
|
+
const seats = await repo.find({ where: { domain: { id: domainId }, session: { id: sessionId }, role: 'member' } as any })
|
|
42
|
+
for (const seat of seats) await repo.softDelete(seat.id!)
|
|
43
|
+
return seats.map(seat => (seat as any).userId).filter(Boolean)
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/** Every door that writes into a conversation passes this first (`session-write-gate.ts`). */
|
|
@@ -123,7 +124,7 @@ class ChatSessionInboxEntry {
|
|
|
123
124
|
hidden?: boolean
|
|
124
125
|
|
|
125
126
|
/* 목록이 공개 범위를 그리고, owner 만 그것을 바꾸는 단추를 받는다 — 판정은 setSessionVisibility 가 다시 한다. */
|
|
126
|
-
@Field({ description: "Who may read the session: 'open' (everyone in the domain) or 'private' (
|
|
127
|
+
@Field({ description: "Who may read the session: 'open' (everyone in the domain), 'group' (the owner and the people brought in) or 'private' (the owner alone)." })
|
|
127
128
|
visibility!: string
|
|
128
129
|
|
|
129
130
|
@Field({ description: 'Whether this viewer is the owner of the session - only the owner may change its visibility.' })
|
|
@@ -176,8 +177,15 @@ export class ChatSessionResolver {
|
|
|
176
177
|
@Arg('id') id: string,
|
|
177
178
|
@Ctx() context: ResolverContext
|
|
178
179
|
): Promise<ChatSession | null> {
|
|
179
|
-
const { domain } = context.state
|
|
180
|
-
|
|
180
|
+
const { domain, user } = context.state
|
|
181
|
+
const session = await getRepository(ChatSession).findOneBy({ id, domain: { id: domain.id } })
|
|
182
|
+
if (!session) return null
|
|
183
|
+
/*
|
|
184
|
+
* The row carries the name, the last preview and the summary of older messages - the conversation in
|
|
185
|
+
* short. A room the caller cannot enter answers as a missing one (ADR-0073: every reading path passes the gate).
|
|
186
|
+
*/
|
|
187
|
+
if (!isOpenRoom((session as any).visibility) && !mayReadSession((session as any).visibility, await isSessionParticipant(domain.id, id, user?.id))) return null
|
|
188
|
+
return session
|
|
181
189
|
}
|
|
182
190
|
|
|
183
191
|
@Query(() => [ChatSession], {
|
|
@@ -453,7 +461,7 @@ export class ChatSessionResolver {
|
|
|
453
461
|
*/
|
|
454
462
|
@Mutation(() => Boolean, {
|
|
455
463
|
description:
|
|
456
|
-
"
|
|
464
|
+
"Set who reads this conversation: 'open' (everyone in the domain), 'group' (the owner and the people the owner brings in) or 'private' (the owner alone - the members are sent out). Only the owner may change it. Opening exposes what is already written — the screen says so before asking. Door checks on assistant answers are unaffected (ADR-0073)."
|
|
457
465
|
})
|
|
458
466
|
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
459
467
|
async setSessionVisibility(
|
|
@@ -476,7 +484,68 @@ export class ChatSessionResolver {
|
|
|
476
484
|
|
|
477
485
|
if ((session as any).visibility === visibility) return true
|
|
478
486
|
|
|
487
|
+
/*
|
|
488
|
+
* Private is the owner's alone (ADR-0073 decision 3). The members leave with the change - a private room
|
|
489
|
+
* with people still seated in it would be a group under another name. Bringing them back is inviting
|
|
490
|
+
* them again, in a group.
|
|
491
|
+
*/
|
|
492
|
+
const sentOut = visibility === 'private' ? await sendOutMembers(domain.id, sessionId) : []
|
|
493
|
+
|
|
479
494
|
await getRepository(ChatSession).update(sessionId, { visibility, updater: user } as any)
|
|
495
|
+
publishSessionActivity({
|
|
496
|
+
domainId: domain.id,
|
|
497
|
+
sessionId,
|
|
498
|
+
kind: 'meta',
|
|
499
|
+
anchorType: (session as any).anchorType,
|
|
500
|
+
anchorId: (session as any).anchorId,
|
|
501
|
+
actorId: user?.id,
|
|
502
|
+
alsoTo: sentOut
|
|
503
|
+
})
|
|
504
|
+
return true
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Bring people into this conversation - the same seat a mention gives, by the same rule (`maySeat`): anyone
|
|
509
|
+
* in an open room, the owner in a group, nobody in a private one. Each person seated is told
|
|
510
|
+
* (`notifyInvited`). People outside the domain are not seated.
|
|
511
|
+
*/
|
|
512
|
+
@Mutation(() => [String], {
|
|
513
|
+
description:
|
|
514
|
+
"Bring people into this conversation and tell them. In a group only the owner may; a private conversation takes nobody (make it a group first). Returns the ids of the people seated now - people already in it are left out."
|
|
515
|
+
})
|
|
516
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
517
|
+
async inviteToAISession(
|
|
518
|
+
@Arg('sessionId') sessionId: string,
|
|
519
|
+
@Arg('userIds', () => [String]) userIds: string[],
|
|
520
|
+
@Ctx() context: ResolverContext
|
|
521
|
+
): Promise<string[]> {
|
|
522
|
+
const { domain, user } = context.state
|
|
523
|
+
const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
|
|
524
|
+
if (!session) throw new Error('chat session not found')
|
|
525
|
+
|
|
526
|
+
/* Only people of this domain - an id from elsewhere is not a colleague here. */
|
|
527
|
+
const wanted = [...new Set((userIds ?? []).filter(id => typeof id === 'string' && id))].slice(0, 50)
|
|
528
|
+
if (wanted.length === 0) return []
|
|
529
|
+
const members = await buildDomainUsersQueryBuilder(domain.id, 'USER').andWhere('USER.id IN (:...ids)', { ids: wanted }).getMany()
|
|
530
|
+
const known = new Set(members.map(member => member.id))
|
|
531
|
+
|
|
532
|
+
const seat = await seatParticipants({
|
|
533
|
+
sessionId,
|
|
534
|
+
domainId: domain.id,
|
|
535
|
+
inviter: user,
|
|
536
|
+
userIds: wanted.filter(id => known.has(id)),
|
|
537
|
+
participants: getRepository(ChatSessionParticipant),
|
|
538
|
+
visibility: (session as any).visibility
|
|
539
|
+
})
|
|
540
|
+
if (seat.notSeated?.length) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
(session as any).visibility === 'private'
|
|
543
|
+
? requestTranslator(context)('ai-assistant.text.invite-private-refused', { defaultValue: 'a private conversation is yours alone. make it a group to bring people in.' })
|
|
544
|
+
: requestTranslator(context)('ai-assistant.text.invite-owner-only', { defaultValue: 'only the person who started this group conversation can bring people in.' })
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
await notifyInvited(context, seat.invited, user, (session as any).name)
|
|
480
549
|
publishSessionActivity({
|
|
481
550
|
domainId: domain.id,
|
|
482
551
|
sessionId,
|
|
@@ -484,7 +553,92 @@ export class ChatSessionResolver {
|
|
|
484
553
|
anchorType: (session as any).anchorType,
|
|
485
554
|
anchorId: (session as any).anchorId,
|
|
486
555
|
actorId: user?.id
|
|
487
|
-
}
|
|
556
|
+
})
|
|
557
|
+
return seat.invited
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Send a member out - the owner only, and never the owner themself. In a group the seat was the key, so the
|
|
562
|
+
* person no longer reads or writes here (every gate asks the seat). Their list is told once and drops it.
|
|
563
|
+
*/
|
|
564
|
+
@Mutation(() => Boolean, {
|
|
565
|
+
description: 'Send a member out of this conversation. Only the owner may, and the owner cannot be sent out. In a group the person no longer reads or writes here.'
|
|
566
|
+
})
|
|
567
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
568
|
+
async removeFromAISession(
|
|
569
|
+
@Arg('sessionId') sessionId: string,
|
|
570
|
+
@Arg('userId') userId: string,
|
|
571
|
+
@Ctx() context: ResolverContext
|
|
572
|
+
): Promise<boolean> {
|
|
573
|
+
const { domain, user } = context.state
|
|
574
|
+
const repo = getRepository(ChatSessionParticipant)
|
|
575
|
+
const seats = await repo.find({ where: { domain: { id: domain.id } as any, session: { id: sessionId } as any } as any })
|
|
576
|
+
const mine = seats.find(seat => (seat as any).userId === user?.id)
|
|
577
|
+
if (mine?.role !== 'owner') {
|
|
578
|
+
throw new Error(requestTranslator(context)('ai-assistant.text.remove-owner-only', { defaultValue: 'only the person who started this conversation can send someone out.' }))
|
|
579
|
+
}
|
|
580
|
+
const theirs = seats.find(seat => (seat as any).userId === userId && seat.role === 'member')
|
|
581
|
+
if (!theirs) return false
|
|
582
|
+
return await this._leave(sessionId, theirs, context)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Leave this conversation - a member steps out of their own seat. The owner does not leave: the room would be
|
|
587
|
+
* nobody's. They delete it, or make it private.
|
|
588
|
+
*/
|
|
589
|
+
@Mutation(() => Boolean, {
|
|
590
|
+
description: 'Leave this conversation. A member may; the owner cannot (delete it or make it private instead). In a group, leaving ends reading and writing here.'
|
|
591
|
+
})
|
|
592
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
593
|
+
async leaveAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
594
|
+
const { domain, user } = context.state
|
|
595
|
+
if (!user?.id) return false
|
|
596
|
+
const mine = await getRepository(ChatSessionParticipant).findOne({
|
|
597
|
+
where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
|
|
598
|
+
})
|
|
599
|
+
if (mine?.role === 'owner') {
|
|
600
|
+
throw new Error(requestTranslator(context)('ai-assistant.text.owner-cannot-leave', { defaultValue: 'you started this conversation, so you cannot leave it. delete it, or make it private.' }))
|
|
601
|
+
}
|
|
602
|
+
if (mine?.role !== 'member') return false
|
|
603
|
+
return await this._leave(sessionId, mine, context)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async _leave(sessionId: string, seat: ChatSessionParticipant, context: ResolverContext): Promise<boolean> {
|
|
607
|
+
const { domain, user } = context.state
|
|
608
|
+
const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
|
|
609
|
+
await getRepository(ChatSessionParticipant).softDelete(seat.id!)
|
|
610
|
+
publishSessionActivity({
|
|
611
|
+
domainId: domain.id,
|
|
612
|
+
sessionId,
|
|
613
|
+
kind: 'meta',
|
|
614
|
+
anchorType: (session as any)?.anchorType,
|
|
615
|
+
anchorId: (session as any)?.anchorId,
|
|
616
|
+
actorId: user?.id,
|
|
617
|
+
alsoTo: [(seat as any).userId].filter(Boolean)
|
|
618
|
+
})
|
|
619
|
+
return true
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* "I am typing" - a notice to the others in the room, stored nowhere. Only a person who may write here is
|
|
624
|
+
* typing here (the write gate), and the listeners pass the room's read gate (`ChatTypingSubscription`).
|
|
625
|
+
*/
|
|
626
|
+
@Mutation(() => Boolean, {
|
|
627
|
+
description: 'Tell the others in this conversation that the caller is typing. Nothing is stored; send it every few seconds while typing. Only a person who may write here may send it.'
|
|
628
|
+
})
|
|
629
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
630
|
+
async sayTyping(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
631
|
+
const { domain, user } = context.state
|
|
632
|
+
const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
|
|
633
|
+
if (!session) return false
|
|
634
|
+
await writeGate(session, context)
|
|
635
|
+
const notice = typingNotice({ domainId: domain.id, sessionId, user })
|
|
636
|
+
if (!notice) return false
|
|
637
|
+
try {
|
|
638
|
+
pubsub.publish(CHAT_TYPING_TOPIC, notice)
|
|
639
|
+
} catch {
|
|
640
|
+
/* a missed notice is a line that does not show - nothing else rests on it */
|
|
641
|
+
}
|
|
488
642
|
return true
|
|
489
643
|
}
|
|
490
644
|
|
|
@@ -583,6 +737,24 @@ export class ChatSessionResolver {
|
|
|
583
737
|
})
|
|
584
738
|
}
|
|
585
739
|
|
|
740
|
+
/**
|
|
741
|
+
* May the caller seat someone here - the server's answer to the screen, so the screen keeps no copy of the
|
|
742
|
+
* rule (`maySeat`). The invite button and the line under a mention read it; the doors decide again.
|
|
743
|
+
*/
|
|
744
|
+
@Query(() => Boolean, {
|
|
745
|
+
description: "Whether the caller may bring people into this conversation now - anyone in an open one, the owner of a group, nobody in a private one. A hint for the screen; the invite and the mention decide again."
|
|
746
|
+
})
|
|
747
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
748
|
+
async mayInviteToAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
749
|
+
const { domain, user } = context.state
|
|
750
|
+
const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
|
|
751
|
+
if (!session || !user?.id) return false
|
|
752
|
+
const seat = await getRepository(ChatSessionParticipant).findOne({
|
|
753
|
+
where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
|
|
754
|
+
})
|
|
755
|
+
return maySeat((session as any).visibility, seat?.role)
|
|
756
|
+
}
|
|
757
|
+
|
|
586
758
|
@Mutation(() => Boolean, {
|
|
587
759
|
description: 'Presence heartbeat — bump current user\'s lastSeenAt in the session (online indicator).'
|
|
588
760
|
})
|
|
@@ -617,7 +789,7 @@ export class ChatSessionResolver {
|
|
|
617
789
|
* 공개 대화는 도메인 안이면 누구나 읽는다 — 초대 절차가 없다(ADR-0073 판정 2). 비공개 대화는
|
|
618
790
|
* 참여자만. 여기서 막히는 것은 **방**이고, 방에 들어온 뒤 값을 읽는 일은 아래 문 판정이 따로 본다.
|
|
619
791
|
*/
|
|
620
|
-
if ((session as any).visibility
|
|
792
|
+
if (!isOpenRoom((session as any).visibility)) {
|
|
621
793
|
/* A hide-only `viewer` row is not being in the room - it must not open a private conversation. */
|
|
622
794
|
const part = await isSessionParticipant(domain.id, sessionId, user?.id)
|
|
623
795
|
if (!mayReadSession((session as any).visibility, part)) return []
|
|
@@ -902,9 +1074,9 @@ export class ChatSessionResolver {
|
|
|
902
1074
|
|
|
903
1075
|
/*
|
|
904
1076
|
* 멘션은 사람을 불러들인다 — **사람의 말을 저장하는 문**만 이것을 한다(ADR-0071 결정 1 ①).
|
|
905
|
-
*
|
|
1077
|
+
* 누가 자리를 주는지는 `maySeat` 가 정한다. 공개 방에서는 말한 사람도 앉는다(`seatTurn`).
|
|
906
1078
|
*/
|
|
907
|
-
const invite = await
|
|
1079
|
+
const invite = await seatTurn({
|
|
908
1080
|
content: body,
|
|
909
1081
|
sessionId: session.id!,
|
|
910
1082
|
domainId: domain.id,
|
|
@@ -913,7 +1085,7 @@ export class ChatSessionResolver {
|
|
|
913
1085
|
/* 비공개 대화에서는 이름만 닿는다 — ADR-0073 판정 2. */
|
|
914
1086
|
visibility: (session as any).visibility
|
|
915
1087
|
})
|
|
916
|
-
await notifyInvited(context, invite.invited, user)
|
|
1088
|
+
await notifyInvited(context, invite.invited, user, (session as any).name)
|
|
917
1089
|
|
|
918
1090
|
/* 사람의 말이다 — 목록의 최근 활동과 다른 참여자의 안 읽음이 이것으로 움직인다. */
|
|
919
1091
|
try {
|
|
@@ -1055,7 +1227,12 @@ export class ChatSessionResolver {
|
|
|
1055
1227
|
saved.push(previous!)
|
|
1056
1228
|
}
|
|
1057
1229
|
|
|
1058
|
-
|
|
1230
|
+
/*
|
|
1231
|
+
* Carried words do not land in front of the whole domain. An open room closes to its owner; a group stays
|
|
1232
|
+
* a group - its people were chosen by the owner, and carrying into it is writing to them.
|
|
1233
|
+
*/
|
|
1234
|
+
if (carried && isOpenRoom((session as any).visibility)) {
|
|
1235
|
+
await sendOutMembers(domain.id, session.id!, tx)
|
|
1059
1236
|
await getRepository(ChatSession, tx).update(session.id!, { visibility: 'private', updater: user } as any)
|
|
1060
1237
|
}
|
|
1061
1238
|
|
|
@@ -74,6 +74,17 @@ describe('which record is meant', () => {
|
|
|
74
74
|
expect(resolveChangeRefs('office.expense-line', [{ ref: 'current', values: {} }], { record: { target: 'office.contract', id: 'c1' } }).unresolved).toEqual(['current'])
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
+
/* 2026-10-23: the open record is named to the model by its key; a change naming it so found nothing. */
|
|
78
|
+
it('★ the key a record was shown by names it - `<axis>:<id>`, the axis being the target key\'s last part', () => {
|
|
79
|
+
const id = 'f92c04ec-40c4-4be8-8160-080cff20e750'
|
|
80
|
+
const picked = (key: string, ref: string) => resolveChangeRefs(key, [{ ref, values: {} }], undefined).picked.map(one => one.id)
|
|
81
|
+
expect(picked('office.contract-round', `contract-round:${id}`)).toEqual([id])
|
|
82
|
+
expect(picked('office.contract-round', id)).toEqual([id])
|
|
83
|
+
/* Another record's key is not this record, whatever its uuid - it is looked up as written and found by nobody. */
|
|
84
|
+
expect(picked('office.contract-round', `contract:${id}`)).toEqual([`contract:${id}`])
|
|
85
|
+
expect(picked('office.contract', `contract-round:${id}`)).toEqual([`contract-round:${id}`])
|
|
86
|
+
})
|
|
87
|
+
|
|
77
88
|
it('★ an id from the screen is only a candidate - a record the person cannot read is not found, and nothing is shown of it', async () => {
|
|
78
89
|
const result: any = await proposeChanges(expense, { changes: [{ ref: 'current', values: { amount: 13000 } }] }, { host: { record: { id: 'someone-elses' } } }, run)
|
|
79
90
|
expect(result.rows[0].issues).toEqual([{ code: 'record-not-found', blocking: true }])
|
|
@@ -202,6 +213,14 @@ describe('declaring and offering', () => {
|
|
|
202
213
|
])
|
|
203
214
|
})
|
|
204
215
|
|
|
216
|
+
/* 2026-10-24: having read 568 bank lines, the model said nothing was selected and asked the person to pick rows. */
|
|
217
|
+
it('★ the change tool takes the ids a list read returned - nothing need be selected', () => {
|
|
218
|
+
const change = (entryCategoryOf(expense, () => run).specs as any[]).find(spec => spec.name === changeToolName('office.expense-line'))
|
|
219
|
+
expect(change.description).toMatch(/the id a list read or a find tool returned/)
|
|
220
|
+
expect(change.description).toMatch(/nothing need be selected to change rows a read found/)
|
|
221
|
+
expect(change.schema.properties.changes.items.properties.ref.description).toMatch(/a list read or a find tool/)
|
|
222
|
+
})
|
|
223
|
+
|
|
205
224
|
it('stillAsProposed marks a record gone since as not found', () => {
|
|
206
225
|
const rows = stillAsProposed(expense, [{ index: 0, id: 'gone', via: 'id', before: { amount: 1 }, values: { amount: 2 }, issues: [] }], new Map())
|
|
207
226
|
expect(rows[0].issues).toEqual([{ code: 'record-not-found', blocking: true }])
|