@things-factory/board-import 10.0.0-beta.108

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 (137) hide show
  1. package/config/config.development.js +44 -0
  2. package/config/config.production.js +18 -0
  3. package/dist-server/index.d.ts +7 -0
  4. package/dist-server/index.js +11 -0
  5. package/dist-server/index.js.map +1 -0
  6. package/dist-server/service/adapters/dxf-adapter.d.ts +8 -0
  7. package/dist-server/service/adapters/dxf-adapter.js +220 -0
  8. package/dist-server/service/adapters/dxf-adapter.js.map +1 -0
  9. package/dist-server/service/adapters/image-adapter.d.ts +32 -0
  10. package/dist-server/service/adapters/image-adapter.js +420 -0
  11. package/dist-server/service/adapters/image-adapter.js.map +1 -0
  12. package/dist-server/service/ai/types.d.ts +185 -0
  13. package/dist-server/service/ai/types.js +378 -0
  14. package/dist-server/service/ai/types.js.map +1 -0
  15. package/dist-server/service/converters/generic-to.d.ts +31 -0
  16. package/dist-server/service/converters/generic-to.js +84 -0
  17. package/dist-server/service/converters/generic-to.js.map +1 -0
  18. package/dist-server/service/extraction/contracts.d.ts +112 -0
  19. package/dist-server/service/extraction/contracts.js +3 -0
  20. package/dist-server/service/extraction/contracts.js.map +1 -0
  21. package/dist-server/service/extraction/index.d.ts +17 -0
  22. package/dist-server/service/extraction/index.js +62 -0
  23. package/dist-server/service/extraction/index.js.map +1 -0
  24. package/dist-server/service/extraction/label-scale-calibrator.d.ts +5 -0
  25. package/dist-server/service/extraction/label-scale-calibrator.js +111 -0
  26. package/dist-server/service/extraction/label-scale-calibrator.js.map +1 -0
  27. package/dist-server/service/extraction/layout-refiner.d.ts +16 -0
  28. package/dist-server/service/extraction/layout-refiner.js +88 -0
  29. package/dist-server/service/extraction/layout-refiner.js.map +1 -0
  30. package/dist-server/service/extraction/pipeline.d.ts +11 -0
  31. package/dist-server/service/extraction/pipeline.js +124 -0
  32. package/dist-server/service/extraction/pipeline.js.map +1 -0
  33. package/dist-server/service/extraction/source-router.d.ts +17 -0
  34. package/dist-server/service/extraction/source-router.js +40 -0
  35. package/dist-server/service/extraction/source-router.js.map +1 -0
  36. package/dist-server/service/extraction/stubs.d.ts +18 -0
  37. package/dist-server/service/extraction/stubs.js +38 -0
  38. package/dist-server/service/extraction/stubs.js.map +1 -0
  39. package/dist-server/service/extraction/vector-geometry.d.ts +7 -0
  40. package/dist-server/service/extraction/vector-geometry.js +21 -0
  41. package/dist-server/service/extraction/vector-geometry.js.map +1 -0
  42. package/dist-server/service/extraction/vlm-recognizer.d.ts +18 -0
  43. package/dist-server/service/extraction/vlm-recognizer.js +78 -0
  44. package/dist-server/service/extraction/vlm-recognizer.js.map +1 -0
  45. package/dist-server/service/import-session/import-actions.d.ts +79 -0
  46. package/dist-server/service/import-session/import-actions.js +98 -0
  47. package/dist-server/service/import-session/import-actions.js.map +1 -0
  48. package/dist-server/service/import-session/import-session-resolver.d.ts +39 -0
  49. package/dist-server/service/import-session/import-session-resolver.js +263 -0
  50. package/dist-server/service/import-session/import-session-resolver.js.map +1 -0
  51. package/dist-server/service/import-session/import-session.d.ts +28 -0
  52. package/dist-server/service/import-session/import-session.js +152 -0
  53. package/dist-server/service/import-session/import-session.js.map +1 -0
  54. package/dist-server/service/import-session/import-worker.d.ts +1 -0
  55. package/dist-server/service/import-session/import-worker.js +121 -0
  56. package/dist-server/service/import-session/import-worker.js.map +1 -0
  57. package/dist-server/service/import-session/index.d.ts +8 -0
  58. package/dist-server/service/import-session/index.js +19 -0
  59. package/dist-server/service/import-session/index.js.map +1 -0
  60. package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
  61. package/dist-server/service/import-session/materialize-from-session.js +104 -0
  62. package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
  63. package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
  64. package/dist-server/service/import-session/suggest-board-name.js +195 -0
  65. package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
  66. package/dist-server/service/import-tools.d.ts +10 -0
  67. package/dist-server/service/import-tools.js +227 -0
  68. package/dist-server/service/import-tools.js.map +1 -0
  69. package/dist-server/service/index.d.ts +26 -0
  70. package/dist-server/service/index.js +60 -0
  71. package/dist-server/service/index.js.map +1 -0
  72. package/dist-server/service/pipeline/index.d.ts +66 -0
  73. package/dist-server/service/pipeline/index.js +127 -0
  74. package/dist-server/service/pipeline/index.js.map +1 -0
  75. package/dist-server/service/pipeline/stage2-mapping.d.ts +29 -0
  76. package/dist-server/service/pipeline/stage2-mapping.js +231 -0
  77. package/dist-server/service/pipeline/stage2-mapping.js.map +1 -0
  78. package/dist-server/service/pipeline/stage3-board.d.ts +52 -0
  79. package/dist-server/service/pipeline/stage3-board.js +108 -0
  80. package/dist-server/service/pipeline/stage3-board.js.map +1 -0
  81. package/dist-server/service/pipeline/stage4-binding.d.ts +9 -0
  82. package/dist-server/service/pipeline/stage4-binding.js +28 -0
  83. package/dist-server/service/pipeline/stage4-binding.js.map +1 -0
  84. package/dist-server/service/registry/index.d.ts +27 -0
  85. package/dist-server/service/registry/index.js +108 -0
  86. package/dist-server/service/registry/index.js.map +1 -0
  87. package/dist-server/service/types/index.d.ts +152 -0
  88. package/dist-server/service/types/index.js +3 -0
  89. package/dist-server/service/types/index.js.map +1 -0
  90. package/dist-server/service/types/registry.d.ts +175 -0
  91. package/dist-server/service/types/registry.js +3 -0
  92. package/dist-server/service/types/registry.js.map +1 -0
  93. package/dist-server/tsconfig.tsbuildinfo +1 -0
  94. package/package.json +35 -0
  95. package/probe/gcp-vision-probe.mjs +114 -0
  96. package/probe/opencv-geometry-probe.py +98 -0
  97. package/server/index.ts +7 -0
  98. package/server/service/adapters/dxf-adapter.ts +230 -0
  99. package/server/service/adapters/image-adapter.test.ts +545 -0
  100. package/server/service/adapters/image-adapter.ts +464 -0
  101. package/server/service/ai/types.ts +619 -0
  102. package/server/service/converters/generic-to.test.ts +105 -0
  103. package/server/service/converters/generic-to.ts +121 -0
  104. package/server/service/extraction/contracts.ts +142 -0
  105. package/server/service/extraction/extraction.test.ts +374 -0
  106. package/server/service/extraction/index.ts +51 -0
  107. package/server/service/extraction/label-scale-calibrator.ts +124 -0
  108. package/server/service/extraction/layout-refiner.ts +104 -0
  109. package/server/service/extraction/pipeline.ts +163 -0
  110. package/server/service/extraction/source-router.ts +45 -0
  111. package/server/service/extraction/stubs.ts +46 -0
  112. package/server/service/extraction/vector-geometry.ts +26 -0
  113. package/server/service/extraction/vlm-recognizer.ts +87 -0
  114. package/server/service/import-session/import-actions.test.ts +185 -0
  115. package/server/service/import-session/import-actions.ts +164 -0
  116. package/server/service/import-session/import-session-resolver.ts +245 -0
  117. package/server/service/import-session/import-session.ts +141 -0
  118. package/server/service/import-session/import-worker.ts +132 -0
  119. package/server/service/import-session/index.ts +24 -0
  120. package/server/service/import-session/materialize-from-session.test.ts +274 -0
  121. package/server/service/import-session/materialize-from-session.ts +158 -0
  122. package/server/service/import-session/suggest-board-name.test.ts +271 -0
  123. package/server/service/import-session/suggest-board-name.ts +279 -0
  124. package/server/service/import-tools.test.ts +137 -0
  125. package/server/service/import-tools.ts +255 -0
  126. package/server/service/index.ts +117 -0
  127. package/server/service/pipeline/index.ts +184 -0
  128. package/server/service/pipeline/stage2-mapping.test.ts +204 -0
  129. package/server/service/pipeline/stage2-mapping.ts +270 -0
  130. package/server/service/pipeline/stage3-board.test.ts +133 -0
  131. package/server/service/pipeline/stage3-board.ts +179 -0
  132. package/server/service/pipeline/stage4-binding.ts +31 -0
  133. package/server/service/registry/index.ts +127 -0
  134. package/server/service/types/index.ts +169 -0
  135. package/server/service/types/registry.ts +186 -0
  136. package/things-factory.config.js +1 -0
  137. package/tsconfig.json +9 -0
@@ -0,0 +1,24 @@
1
+ import { ImportSession } from './import-session'
2
+ import { ImportSessionResolver } from './import-session-resolver'
3
+
4
+ export { ImportSession, ImportSessionResolver }
5
+ export { runImportJob } from './import-worker'
6
+ export {
7
+ startImportJobAsync,
8
+ fetchImportSession,
9
+ listImportSessionsByChatSession,
10
+ describeAdapter,
11
+ type StartImportJobInput,
12
+ type ImportActionsContext,
13
+ type SessionRepoLike,
14
+ type AdapterInfo
15
+ } from './import-actions'
16
+ export {
17
+ materializeFromSession,
18
+ type MaterializeRequest,
19
+ type MaterializeContext,
20
+ type MaterializeDeps
21
+ } from './materialize-from-session'
22
+
23
+ export const entities = [ImportSession]
24
+ export const resolverClasses = [ImportSessionResolver]
@@ -0,0 +1,274 @@
1
+ /**
2
+ * materializeFromSession — 단위 테스트.
3
+ *
4
+ * Resolver IO (typeorm getRepository, ResolverContext) 와 분리된 순수 비즈니스 로직 검증.
5
+ * mock repository 로 검증 시나리오 / Board persist 정책 / 예외 케이스 회귀 방지.
6
+ */
7
+ import {
8
+ materializeFromSession,
9
+ type BoardRepoLike,
10
+ type GroupRepoLike,
11
+ type MaterializeContext,
12
+ type SessionLike
13
+ } from './materialize-from-session'
14
+
15
+ // ── Test fixtures ────────────────────────────────────────────────────
16
+
17
+ const DOMAIN = { id: 'domain-1', name: 'test-domain' }
18
+ const USER = { id: 'user-1', name: 'tester' }
19
+ const CONTEXT: MaterializeContext = { domain: DOMAIN, user: USER }
20
+
21
+ const SESSION_BASE: SessionLike = {
22
+ id: 'session-1',
23
+ status: 'completed',
24
+ result: {
25
+ boardModel: {
26
+ width: 1920,
27
+ height: 1080,
28
+ components: [{ refid: 1, type: 'rect' }]
29
+ }
30
+ }
31
+ }
32
+
33
+ function makeBoardRepo(overrides: Partial<BoardRepoLike> = {}): BoardRepoLike & {
34
+ saveCalls: any[]
35
+ findOneByCalls: any[]
36
+ } {
37
+ const findOneByCalls: any[] = []
38
+ const saveCalls: any[] = []
39
+ const repo: any = {
40
+ findOneByCalls,
41
+ saveCalls,
42
+ findOneBy: async (criteria: any) => {
43
+ findOneByCalls.push(criteria)
44
+ return null
45
+ },
46
+ save: async (entity: any) => {
47
+ saveCalls.push(entity)
48
+ return { ...entity, id: 'new-board-1' }
49
+ },
50
+ ...overrides
51
+ }
52
+ return repo
53
+ }
54
+
55
+ function makeGroupRepo(overrides: Partial<GroupRepoLike> = {}): GroupRepoLike {
56
+ return {
57
+ findOneBy: async () => null,
58
+ ...overrides
59
+ }
60
+ }
61
+
62
+ const baseInput = {
63
+ sessionId: 'session-1',
64
+ name: 'New Imported Board'
65
+ }
66
+
67
+ // ── 검증 / 예외 ──────────────────────────────────────────────────────
68
+
69
+ describe('materializeFromSession — 검증', () => {
70
+ test('session 이 null 이면 not found 에러', async () => {
71
+ await expect(
72
+ materializeFromSession(null, baseInput, CONTEXT, {
73
+ boardRepo: makeBoardRepo(),
74
+ groupRepo: makeGroupRepo()
75
+ })
76
+ ).rejects.toThrow(/not found/)
77
+ })
78
+
79
+ test("status 가 'completed' 가 아니면 cannot materialize 에러", async () => {
80
+ const session: SessionLike = { ...SESSION_BASE, status: 'parsing' }
81
+ await expect(
82
+ materializeFromSession(session, baseInput, CONTEXT, {
83
+ boardRepo: makeBoardRepo(),
84
+ groupRepo: makeGroupRepo()
85
+ })
86
+ ).rejects.toThrow(/cannot materialize/)
87
+ })
88
+
89
+ test('result.boardModel 이 없으면 no boardModel 에러', async () => {
90
+ const session: SessionLike = { ...SESSION_BASE, result: { boardModel: undefined } }
91
+ await expect(
92
+ materializeFromSession(session, baseInput, CONTEXT, {
93
+ boardRepo: makeBoardRepo(),
94
+ groupRepo: makeGroupRepo()
95
+ })
96
+ ).rejects.toThrow(/no boardModel/)
97
+ })
98
+
99
+ test('이름이 같은 도메인에 이미 있으면 already taken 에러', async () => {
100
+ const boardRepo = makeBoardRepo({
101
+ findOneBy: async () => ({ id: 'existing-1', name: baseInput.name })
102
+ })
103
+ await expect(
104
+ materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
105
+ boardRepo: boardRepo as any,
106
+ groupRepo: makeGroupRepo()
107
+ })
108
+ ).rejects.toThrow(/already taken/)
109
+ })
110
+ })
111
+
112
+ // ── 정상 흐름 ────────────────────────────────────────────────────────
113
+
114
+ describe('materializeFromSession — Board 생성 정책', () => {
115
+ test("저장 시 state='draft' + sourceImportSessionId=session.id", async () => {
116
+ const boardRepo = makeBoardRepo()
117
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
118
+ boardRepo: boardRepo as any,
119
+ groupRepo: makeGroupRepo()
120
+ })
121
+ expect(boardRepo.saveCalls).toHaveLength(1)
122
+ const saved = boardRepo.saveCalls[0]
123
+ expect(saved.state).toBe('draft')
124
+ expect(saved.sourceImportSessionId).toBe('session-1')
125
+ })
126
+
127
+ test('boardModel 이 JSON 문자열로 직렬화되어 model 필드에 저장됨', async () => {
128
+ const boardRepo = makeBoardRepo()
129
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
130
+ boardRepo: boardRepo as any,
131
+ groupRepo: makeGroupRepo()
132
+ })
133
+ const saved = boardRepo.saveCalls[0]
134
+ expect(typeof saved.model).toBe('string')
135
+ expect(JSON.parse(saved.model)).toEqual(SESSION_BASE.result!.boardModel)
136
+ })
137
+
138
+ test("type 미지정 시 default 'main'", async () => {
139
+ const boardRepo = makeBoardRepo()
140
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
141
+ boardRepo: boardRepo as any,
142
+ groupRepo: makeGroupRepo()
143
+ })
144
+ expect(boardRepo.saveCalls[0].type).toBe('main')
145
+ })
146
+
147
+ test("type='popup' 입력 시 그대로 저장", async () => {
148
+ const boardRepo = makeBoardRepo()
149
+ await materializeFromSession(
150
+ SESSION_BASE,
151
+ { ...baseInput, type: 'popup' },
152
+ CONTEXT,
153
+ { boardRepo: boardRepo as any, groupRepo: makeGroupRepo() }
154
+ )
155
+ expect(boardRepo.saveCalls[0].type).toBe('popup')
156
+ })
157
+
158
+ test('thumbnail 미지정 시 기본 empty thumbnail 사용', async () => {
159
+ const boardRepo = makeBoardRepo()
160
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
161
+ boardRepo: boardRepo as any,
162
+ groupRepo: makeGroupRepo()
163
+ })
164
+ expect(boardRepo.saveCalls[0].thumbnail).toMatch(/^data:image\/gif;base64,/)
165
+ })
166
+
167
+ test('thumbnail 지정 시 그대로 저장', async () => {
168
+ const boardRepo = makeBoardRepo()
169
+ const customThumbnail = 'data:image/png;base64,custom'
170
+ await materializeFromSession(
171
+ SESSION_BASE,
172
+ { ...baseInput, thumbnail: customThumbnail },
173
+ CONTEXT,
174
+ { boardRepo: boardRepo as any, groupRepo: makeGroupRepo() }
175
+ )
176
+ expect(boardRepo.saveCalls[0].thumbnail).toBe(customThumbnail)
177
+ })
178
+
179
+ test('groupId 지정 시 groupRepo 조회 결과를 group 으로 attach', async () => {
180
+ const boardRepo = makeBoardRepo()
181
+ const group = { id: 'group-1', name: 'test-group' }
182
+ const groupRepo = makeGroupRepo({ findOneBy: async () => group })
183
+ await materializeFromSession(
184
+ SESSION_BASE,
185
+ { ...baseInput, groupId: 'group-1' },
186
+ CONTEXT,
187
+ { boardRepo: boardRepo as any, groupRepo }
188
+ )
189
+ expect(boardRepo.saveCalls[0].group).toEqual(group)
190
+ })
191
+
192
+ test('groupId 미지정 시 group=undefined 로 저장', async () => {
193
+ const boardRepo = makeBoardRepo()
194
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
195
+ boardRepo: boardRepo as any,
196
+ groupRepo: makeGroupRepo()
197
+ })
198
+ expect(boardRepo.saveCalls[0].group).toBeUndefined()
199
+ })
200
+
201
+ test('createQueryBuilder 미지원 환경에서 sortOrder=0 fallback', async () => {
202
+ const boardRepo = makeBoardRepo()
203
+ expect(boardRepo.createQueryBuilder).toBeUndefined()
204
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
205
+ boardRepo: boardRepo as any,
206
+ groupRepo: makeGroupRepo()
207
+ })
208
+ expect(boardRepo.saveCalls[0].sortOrder).toBe(0)
209
+ })
210
+
211
+ test('createQueryBuilder 지원 환경에서 sortOrder = MIN(sortOrder) - 1', async () => {
212
+ const boardRepo = makeBoardRepo({
213
+ createQueryBuilder: () =>
214
+ ({
215
+ select: function (this: any) {
216
+ return this
217
+ },
218
+ where: function (this: any) {
219
+ return this
220
+ },
221
+ getRawOne: async () => ({ minSortOrder: 5 })
222
+ }) as any
223
+ })
224
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
225
+ boardRepo: boardRepo as any,
226
+ groupRepo: makeGroupRepo()
227
+ })
228
+ expect(boardRepo.saveCalls[0].sortOrder).toBe(4)
229
+ })
230
+
231
+ test('빈 보드 (minSortOrder=null) 환경에서 sortOrder=0', async () => {
232
+ const boardRepo = makeBoardRepo({
233
+ createQueryBuilder: () =>
234
+ ({
235
+ select: function (this: any) {
236
+ return this
237
+ },
238
+ where: function (this: any) {
239
+ return this
240
+ },
241
+ getRawOne: async () => ({ minSortOrder: null })
242
+ }) as any
243
+ })
244
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
245
+ boardRepo: boardRepo as any,
246
+ groupRepo: makeGroupRepo()
247
+ })
248
+ expect(boardRepo.saveCalls[0].sortOrder).toBe(0)
249
+ })
250
+
251
+ test('domain 과 creator/updater 가 context 에서 그대로 전파', async () => {
252
+ const boardRepo = makeBoardRepo()
253
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
254
+ boardRepo: boardRepo as any,
255
+ groupRepo: makeGroupRepo()
256
+ })
257
+ const saved = boardRepo.saveCalls[0]
258
+ expect(saved.domain).toBe(DOMAIN)
259
+ expect(saved.creator).toBe(USER)
260
+ expect(saved.updater).toBe(USER)
261
+ })
262
+
263
+ test('이름 충돌 검사가 도메인 id 기반', async () => {
264
+ const boardRepo = makeBoardRepo()
265
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
266
+ boardRepo: boardRepo as any,
267
+ groupRepo: makeGroupRepo()
268
+ })
269
+ expect(boardRepo.findOneByCalls[0]).toEqual({
270
+ name: baseInput.name,
271
+ domain: { id: DOMAIN.id }
272
+ })
273
+ })
274
+ })
@@ -0,0 +1,158 @@
1
+ /**
2
+ * materializeFromSession — ImportSession.result.boardModel → 신규 Board 영속화의 **순수 비즈니스
3
+ * 로직**. typeorm `getRepository` / `ResolverContext` 같은 외부 의존성을 인자로 분리해서 단위
4
+ * 테스트 가능하도록 구성. resolver 측 mutation 은 IO (repo, ctx) 만 wiring 해서 이 함수를 호출.
5
+ *
6
+ * 호출 책임:
7
+ * - resolver: getRepository / context.state 에서 도메인/유저 추출
8
+ * - 이 함수: 검증 → 정책 적용 → save 호출 → 결과 반환
9
+ *
10
+ * 정책:
11
+ * - ImportSession.status 가 'completed' 가 아니면 거절
12
+ * - result.boardModel 이 없으면 거절
13
+ * - Board.name 이 같은 도메인에 이미 있으면 거절 (createBoard 와 동일 정책)
14
+ * - 새 보드는 sortOrder = (현재 최소 sortOrder - 1) 또는 0 (createBoard 와 동일 정책)
15
+ * - state='draft', sourceImportSessionId=session.id, model=JSON.stringify(boardModel)
16
+ */
17
+
18
+ const EMPTY_THUMBNAIL = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
19
+
20
+ export interface MaterializeRequest {
21
+ sessionId: string
22
+ name: string
23
+ description?: string
24
+ groupId?: string
25
+ type?: 'main' | 'sub' | 'popup'
26
+ thumbnail?: string
27
+ /** 사용자가 선택한 시안 id. 미지정 시 session.result.defaultVariantId 또는 첫 variant. */
28
+ variantId?: string
29
+ }
30
+
31
+ export interface SessionLike {
32
+ id: string
33
+ status: string
34
+ result?: {
35
+ boardModel?: any
36
+ variants?: Array<{ id: string; boardModel: any }>
37
+ defaultVariantId?: string
38
+ } | null
39
+ }
40
+
41
+ export interface BoardRepoLike {
42
+ findOneBy(criteria: any): Promise<any | null>
43
+ save(entity: any): Promise<any>
44
+ /** sortOrder 결정용 — 미존재 시 0 으로 fallback. */
45
+ createQueryBuilder?(alias: string): {
46
+ select(expr: string, alias: string): any
47
+ where(cond: string, params: any): any
48
+ getRawOne(): Promise<any>
49
+ }
50
+ }
51
+
52
+ export interface GroupRepoLike {
53
+ findOneBy(criteria: any): Promise<any | null>
54
+ }
55
+
56
+ export interface MaterializeContext {
57
+ domain: { id: string; [k: string]: any }
58
+ user: any
59
+ }
60
+
61
+ export interface MaterializeDeps {
62
+ boardRepo: BoardRepoLike
63
+ groupRepo: GroupRepoLike
64
+ }
65
+
66
+ /**
67
+ * ImportSession 결과를 새 Board 로 저장. 검증 실패 시 throw, 성공 시 저장된 Board 반환.
68
+ */
69
+ export async function materializeFromSession(
70
+ session: SessionLike | null,
71
+ input: MaterializeRequest,
72
+ context: MaterializeContext,
73
+ deps: MaterializeDeps
74
+ ): Promise<any> {
75
+ if (!session) {
76
+ throw new Error(`ImportSession ${input.sessionId} not found`)
77
+ }
78
+ if (session.status !== 'completed') {
79
+ throw new Error(
80
+ `ImportSession ${input.sessionId} is not completed (status=${session.status}); cannot materialize.`
81
+ )
82
+ }
83
+ const boardModel = pickVariantBoardModel(session.result, input.variantId)
84
+ if (!boardModel) {
85
+ throw new Error(`ImportSession ${input.sessionId} has no boardModel in result.`)
86
+ }
87
+
88
+ const existing = await deps.boardRepo.findOneBy({
89
+ name: input.name,
90
+ domain: { id: context.domain.id }
91
+ })
92
+ if (existing) {
93
+ throw new Error(`Board name '${input.name}' is already taken`)
94
+ }
95
+
96
+ const group = input.groupId ? (await deps.groupRepo.findOneBy({ id: input.groupId })) ?? undefined : undefined
97
+
98
+ const nextSortOrder = await resolveNextSortOrder(deps.boardRepo, context.domain.id)
99
+
100
+ return await deps.boardRepo.save({
101
+ domain: context.domain,
102
+ name: input.name,
103
+ description: input.description,
104
+ type: input.type ?? 'main',
105
+ thumbnail: input.thumbnail || EMPTY_THUMBNAIL,
106
+ model: JSON.stringify(boardModel),
107
+ sourceImportSessionId: session.id,
108
+ group,
109
+ sortOrder: nextSortOrder,
110
+ state: 'draft',
111
+ creator: context.user,
112
+ updater: context.user
113
+ })
114
+ }
115
+
116
+ /**
117
+ * 시안 (variant) 선택 — 사용자가 명시한 variantId > session.result.defaultVariantId > 첫 variant
118
+ * > 구버전 호환 (session.result.boardModel).
119
+ */
120
+ function pickVariantBoardModel(
121
+ result: SessionLike['result'],
122
+ variantId?: string
123
+ ): any | undefined {
124
+ if (!result) return undefined
125
+ const variants = Array.isArray(result.variants) ? result.variants : []
126
+ if (variants.length > 0) {
127
+ if (variantId) {
128
+ const found = variants.find(v => v.id === variantId)
129
+ if (found?.boardModel) return found.boardModel
130
+ }
131
+ if (result.defaultVariantId) {
132
+ const found = variants.find(v => v.id === result.defaultVariantId)
133
+ if (found?.boardModel) return found.boardModel
134
+ }
135
+ if (variants[0]?.boardModel) return variants[0].boardModel
136
+ }
137
+ // back-compat — 구버전 ImportSession (variants 미존재) 의 단일 boardModel
138
+ return result.boardModel
139
+ }
140
+
141
+ /**
142
+ * sortOrder = MIN(sortOrder) - 1, 미존재 시 0. createBoard 의 "맨 앞에 배치" 정책과 동일.
143
+ * createQueryBuilder 미지원 환경 (예: 단위 테스트의 minimal mock) 에서는 0 fallback.
144
+ */
145
+ async function resolveNextSortOrder(repo: BoardRepoLike, domainId: string): Promise<number> {
146
+ if (!repo.createQueryBuilder) return 0
147
+ try {
148
+ const raw = await repo
149
+ .createQueryBuilder('board')
150
+ .select('MIN(board.sortOrder)', 'minSortOrder')
151
+ .where('board.domain = :domainId', { domainId })
152
+ .getRawOne()
153
+ const min = raw?.minSortOrder
154
+ return typeof min === 'number' && Number.isFinite(min) ? min - 1 : 0
155
+ } catch {
156
+ return 0
157
+ }
158
+ }