@things-factory/board-ai 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/board-ai",
3
- "version": "10.1.207",
3
+ "version": "10.1.210",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/client/index.js",
6
6
  "things-factory": true,
@@ -32,12 +32,12 @@
32
32
  "@operato/i18n": "^10.0.0",
33
33
  "@operato/scene-ops": "^10.14.1",
34
34
  "@operato/styles": "^10.0.0",
35
- "@things-factory/ai-assistant": "^10.1.207",
36
- "@things-factory/ai-client-base": "^10.1.207",
37
- "@things-factory/auth-base": "^10.1.206",
38
- "@things-factory/board-import": "^10.1.207",
35
+ "@things-factory/ai-assistant": "^10.1.210",
36
+ "@things-factory/ai-client-base": "^10.1.208",
37
+ "@things-factory/auth-base": "^10.1.208",
38
+ "@things-factory/board-import": "^10.1.208",
39
39
  "@things-factory/env": "^10.1.149",
40
- "@things-factory/shell": "^10.1.206",
40
+ "@things-factory/shell": "^10.1.208",
41
41
  "dompurify": "^3.0.0",
42
42
  "graphql-tag": "^2.12.6",
43
43
  "lit": "^3.1.2",
@@ -47,5 +47,5 @@
47
47
  "copyfiles": "^2.4.1",
48
48
  "rimraf": "^5.0.0"
49
49
  },
50
- "gitHead": "e6e3fbf5c353629adbeb8850093150b14254e059"
50
+ "gitHead": "89fbda12d43d8532ba4e95ba6d6d7e05e2da2502"
51
51
  }
@@ -45,13 +45,11 @@ import { publishChatMessage } from '@things-factory/ai-assistant'
45
45
  import { PatchEntry } from './patch-entry/patch-entry.js'
46
46
  import { publishBoardPatch } from './patch-entry/board-patch-subscription.js'
47
47
  import { ChatSessionParticipant } from '@things-factory/ai-assistant'
48
- import { buildInviteNotification } from '@things-factory/ai-assistant'
48
+ import { seatTurn, sendInviteNotification } from '@things-factory/ai-assistant'
49
49
  import {
50
50
  injectMentionRefids,
51
51
  injectAiMentionRefids,
52
- stripMentionRefids,
53
- extractMentionUserIds,
54
- newParticipantUserIds
52
+ stripMentionRefids
55
53
  } from './mention-marker.js'
56
54
 
57
55
  @InputType()
@@ -392,7 +390,9 @@ export class BoardAIChatResolver {
392
390
  // C4: @user 멘션 → 세션 참여자(member) 자동 등록 (초대). best-effort — 실패해도 chat 흐름 무영향.
393
391
  // enriched content 의 {userId:UUID} 마커에서 추출 → 기존 참여자/자기 제외한 신규만 member 로 등록.
394
392
  try {
395
- await this._inviteMentionedUsers(enriched, session.id!, domain.id, user, session.boardId, (context.state as any).notify)
393
+ await this._inviteMentionedUsers(enriched, session as any, domain.id, user, (context.state as any).notify, (key, options) =>
394
+ typeof (context as any).t === 'function' ? (context as any).t(key, options) : String(options?.defaultValue ?? key)
395
+ )
396
396
  } catch {
397
397
  /* 초대 실패는 대화를 막지 않는다 */
398
398
  }
@@ -691,66 +691,30 @@ export class BoardAIChatResolver {
691
691
  }
692
692
 
693
693
  /**
694
- * C4: 메시지에 @멘션된 사용자를 세션 참여자(member)로 자동 등록 — 초대.
695
- * enriched content 의 {userId:UUID} 마커에서 추출 → 기존 참여자·자기 제외한 신규만 등록.
696
- * 소프트삭제(deletedAt)된 과거 참여자는 다시 살린다(재초대). 신규 등록 후 협업 초대 알림 발송(C4b-2, best-effort).
694
+ * C4: 메시지에 @멘션된 사용자를 세션 참여자(member)로 등록 — 초대.
695
+ *
696
+ * 자리는 ai-assistant 의 한 곳(`seatTurn` — 공개 방의 글쓴이, 그리고 멘션 → `seatParticipants`)이 준다. 여기에 따로 있던 사본은 공개
697
+ * 범위를 보지 않아, 그룹 · 비공개 보드 대화에서도 누구의 멘션이든 사람을 앉혔다(2026-10-24, ADR-0073 결정 3).
698
+ * 알림도 같은 한 곳(`sendInviteNotification`) — 보드 문구와 모델러 링크.
697
699
  */
698
700
  private async _inviteMentionedUsers(
699
701
  content: string,
700
- sessionId: string,
702
+ session: { id?: string; boardId?: string; name?: string; visibility?: string },
701
703
  domainId: string,
702
704
  inviter: any,
703
- boardId?: string,
704
- notify?: (arg: any) => Promise<void>
705
+ notify?: (arg: any) => Promise<void>,
706
+ t?: (key: string, options: Record<string, unknown>) => string
705
707
  ): Promise<void> {
706
- const mentioned = extractMentionUserIds(content)
707
- if (mentioned.length === 0) return
708
-
709
- const repo = getRepository(ChatSessionParticipant)
710
- const existing = await repo.find({
711
- where: { session: { id: sessionId } as any, domain: { id: domainId } as any },
712
- withDeleted: true
708
+ const { invited } = await seatTurn({
709
+ content,
710
+ sessionId: session.id!,
711
+ domainId,
712
+ inviter,
713
+ participants: getRepository(ChatSessionParticipant),
714
+ visibility: session.visibility
713
715
  })
714
- const activeIds = existing.filter(p => !p.deletedAt).map(p => p.userId!).filter(Boolean)
715
- const fresh = newParticipantUserIds(mentioned, activeIds, inviter?.id)
716
- if (fresh.length === 0) return
717
-
718
- for (const userId of fresh) {
719
- // 과거에 나갔던(soft-deleted) 참여자면 되살리고, 아니면 새로 등록.
720
- const prior = existing.find(p => p.userId === userId && p.deletedAt)
721
- if (prior) {
722
- await repo.update(prior.id!, { deletedAt: null as any, role: 'member', updater: inviter } as any)
723
- } else {
724
- await repo.save({
725
- domain: { id: domainId } as any,
726
- session: { id: sessionId } as any,
727
- user: { id: userId } as any,
728
- role: 'member',
729
- creator: inviter,
730
- updater: inviter
731
- } as any)
732
- }
733
- }
734
-
735
- // C4b-2: 초대 알림 — notification 모듈의 기존 발송 메커니즘 재사용(중복 구현 금지).
736
- // context.state.notify 는 notification-middleware 가 전역 주입(FCM/pubsub). duck-typed →
737
- // board-ai 에 의존성 추가 없이 런타임 호출. 미로드 환경이면 undefined → graceful skip.
738
- this._notifyInvited(notify, fresh, boardId, inviter)
716
+ await sendInviteNotification(notify, invited, inviter, session.name, session.boardId, t)
739
717
  }
740
718
 
741
- /** 초대된 사용자들에게 협업 초대 알림 발송 (best-effort, capability 있을 때만). */
742
- private _notifyInvited(
743
- notify: ((arg: any) => Promise<void>) | undefined,
744
- userIds: string[],
745
- boardId: string | undefined,
746
- inviter: any
747
- ): void {
748
- if (typeof notify !== 'function') return
749
- const payload = buildInviteNotification(userIds, boardId, inviter)
750
- if (!payload) return
751
- Promise.resolve(notify(payload)).catch(() => {
752
- /* 알림 실패는 초대·대화를 막지 않는다 */
753
- })
754
- }
755
719
  }
756
720
 
@@ -30,8 +30,7 @@ import { publishSessionActivity } from '@things-factory/ai-assistant'
30
30
  import { ChatMessage } from '@things-factory/ai-assistant'
31
31
  import { publishChatMessage } from '@things-factory/ai-assistant'
32
32
  import { ChatSessionResolver } from '@things-factory/ai-assistant'
33
- import { ChatSessionParticipant } from '@things-factory/ai-assistant'
34
- import { mayReadSession } from '@things-factory/ai-assistant'
33
+ import { isSessionParticipant, mayReadSession } from '@things-factory/ai-assistant'
35
34
  import { PatchEntry } from './patch-entry/patch-entry.js'
36
35
  import { mayRevertPatch } from './patch-entry/revert-permission.js'
37
36
  import { publishBoardPatch } from './patch-entry/board-patch-subscription.js'
@@ -187,13 +186,8 @@ export class BoardSessionResolver {
187
186
  relations: ['session']
188
187
  })
189
188
  const session: any = patch?.session
190
- const isParticipant =
191
- !!session && !!user?.id
192
- ? !!(await getRepository(ChatSessionParticipant, tx).findOneBy({
193
- session: { id: session.id },
194
- user: { id: user.id }
195
- } as any))
196
- : false
189
+ /* The room's own lookup - a hide-only `viewer` row is not a seat, and a group or private room asks it (ADR-0073). */
190
+ const isParticipant = !!session && (await isSessionParticipant(domain.id, session.id, user?.id, tx))
197
191
 
198
192
  if (!mayRevertPatch(patch ? { sessionVisibility: session?.visibility } : null, isParticipant, mayReadSession)) {
199
193
  throw new Error(`PatchEntry ${patchId} not found`)
@@ -0,0 +1,28 @@
1
+ /*
2
+ * A board conversation seats people the way every conversation does (ADR-0073 decision 3).
3
+ *
4
+ * board-ai had its own copy of "a mention seats the person": it never asked the room's visibility, so in a group
5
+ * or private board conversation anyone's mention seated anyone. The seat now comes from ai-assistant's one place,
6
+ * which asks `maySeat`. This holds the resolver to it - a second copy fails here.
7
+ */
8
+ import { describe, it } from 'node:test'
9
+ import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+
13
+ const resolver = readFileSync(join(import.meta.dirname, '../server/service/board-ai-resolver.ts'), 'utf-8')
14
+
15
+ describe('a board conversation seats people through the one place', () => {
16
+ it('★ the turn\'s seats (writer, then mentions) go through seatTurn, with the room visibility', () => {
17
+ assert.match(resolver, /await seatTurn\(\{[\s\S]*?visibility: session\.visibility[\s\S]*?\}\)/)
18
+ })
19
+
20
+ it('★ no seat is written here - no member row, no own mention parser', () => {
21
+ assert.doesNotMatch(resolver, /role: 'member'/)
22
+ assert.doesNotMatch(resolver, /newParticipantUserIds|extractMentionUserIds/)
23
+ })
24
+
25
+ it('the invite is told the shared way, with the board link', () => {
26
+ assert.match(resolver, /sendInviteNotification\(notify, invited, inviter, session\.name, session\.boardId, t\)/)
27
+ })
28
+ })