import { twinError } from '../../engine/log.js' import { Arg, Ctx, Mutation, Query, Resolver, Directive} from 'type-graphql' import { ScalarObject, getRepository } from '@things-factory/shell' import { TwinSpace } from './twin-space.js' import { TwinSpaceRepresentation } from './twin-space-representation.js' import { REPRESENTATION_ROLES } from './representation-role.js' import { TwinSpaceArea } from './twin-space-area.js' import { TwinArea } from './twin-area.js' import { TwinInstance } from '../twin-instance/twin-instance.js' import { TwinAuditEvent } from '../twin-audit/twin-audit-event.js' import { TwinEngine } from '../../engine/index.js' import { judgeMoveTwinSpace, judgeDetachTwinSpace } from './move-space.js' import { checkSpaceIntegrity, sortFindings, type IntegrityPolygon } from './space-integrity.js' /* * 공간 구성 채널(design/ui/space-composition.md) — Site(Space) + 복수 표현(Representation) + 영역(Area). * model 내용은 board-service Board 에(표현 config.boardId 참조, 중복 저장 금지). 모두 domain 스코프. */ @Resolver() export class TwinSpaceResolver { /* ── Space(Site) ────────────────────────────────────────────────── */ /** * 이 테넌트의 현장 목록. * * ── 세 번만 묻는다 (2026-08-13) ────────────────────────────────────────── * 예전에는 현장마다 표현 수를 세고 인스턴스를 조회했다 — 현장 13곳이면 **질의 27번**이고, 첫 * 호출이 2.4초였다(헤더가 뜰 때마다 이 목록을 읽는다). 규모가 커지면 그대로 자란다. * * 이제 세 번 묻고 메모리에서 맞춘다: 현장 · 표현(공간 id 만) · 인스턴스(필요한 컬럼만). * 표현은 **id 만 실어 온다**(`loadRelationIds`) — 설정 blob 을 읽어 세는 것은 낭비다. */ @Query(returns => [ScalarObject], { description: 'List tenant Spaces (Sites) in the current domain.' }) async twinSpaces(@Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id const [rows, reps, insts] = await Promise.all([ getRepository(TwinSpace).find({ where: { domain: { id: domainId } }, order: { createdAt: 'ASC' } }), /* 관계를 **조인해서** 읽는다 — `loadRelationIds`+`select` 조합은 관계 자리를 비워 두어 표현 수가 전부 0 이 됐다(2026-08-13 실측으로 잡음). 조인 한 번이 현장마다 세는 것보다 싸다. */ getRepository(TwinSpaceRepresentation).find({ where: { domain: { id: domainId } }, relations: { space: true } }), getRepository(TwinInstance).find({ where: { domain: { id: domainId } }, select: { instanceId: true, spaceId: true, purpose: true, copyOf: true } }) ]) const repCount = new Map() for (const rep of reps) { const sid = rep.space?.id if (sid) repCount.set(sid, (repCount.get(sid) ?? 0) + 1) } /* 벤치 여부·원본 참조 — 인스턴스 purpose 근거(1급). 사본 현장을 헤더 선택기에서 구별·선택 가능하게. */ const benchOf = new Map() for (const i of insts) if ((i as any).purpose === 'bench' && i.spaceId && !benchOf.has(i.spaceId)) benchOf.set(i.spaceId, { copyOf: i.copyOf ?? undefined }) return rows.map(r => { const bench = benchOf.get(r.spaceId) return { spaceId: r.spaceId, name: r.name, description: r.description, geo: r.geo, timezone: r.timezone, primaryRepresentationId: r.primaryRepresentationId, extentX: r.extentX, extentY: r.extentY, representationCount: repCount.get(r.id) ?? 0, bench: !!bench, copyOf: bench?.copyOf } }) } /** 단건 — 표현(+영역) 트리 포함. */ @Query(returns => ScalarObject, { nullable: true, description: 'Get one Space with its representations (and their areas).' }) async twinSpace(@Arg('spaceId') spaceId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id const r = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId } }) if (!r) return null /* * 묶은 이름 다음에 순서로 정렬한다. `group` 이 비어 있는 행이 앞에 오는지 뒤에 오는지는 드라이버마다 * 다르므로 그 순서에 기대지 않는다 — 화면이 다시 묶는다(UX 레인과 그렇게 정했다). */ const reps = await getRepository(TwinSpaceRepresentation).find({ where: { domain: { id: domainId }, space: { id: r.id } }, order: { group: 'ASC', seq: 'ASC' } }) const representations: any[] = [] for (const rep of reps) { const areas = await getRepository(TwinSpaceArea).find({ where: { domain: { id: domainId }, representation: { id: rep.id } }, order: { createdAt: 'ASC' } }) representations.push({ id: rep.id, type: rep.type, name: rep.name, seq: rep.seq, group: rep.group, role: rep.role, isPrimary: rep.isPrimary, config: rep.config, areas: areas.map(a => ({ id: a.id, name: a.name, type: a.type, geometry: a.geometry, drillTo: a.drillTo, binding: a.binding, style: a.style })) }) } return { spaceId: r.spaceId, name: r.name, description: r.description, geo: r.geo, timezone: r.timezone, primaryRepresentationId: r.primaryRepresentationId, extentX: r.extentX, extentY: r.extentY, representations } } @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Create or update a Space (name/description/geo/timezone/primaryRepresentationId).' }) async saveTwinSpace( @Arg('spaceId') spaceId: string, @Arg('patch', type => ScalarObject) patch: any, @Ctx() context: ResolverContext ): Promise { const repo = getRepository(TwinSpace) const domainId = context.state.domain.id const existing = await repo.findOne({ where: { domain: { id: domainId }, spaceId } }) /* 진짜 부분 병합 — patch 에 준 키만 갱신(나머지 필드 보존). id 는 관계로 재설정하지 않도록 제외. */ const { id: _drop, ...clean } = patch ?? {} await repo.save(repo.create({ ...(existing ?? {}), ...clean, domain: { id: domainId } as any, spaceId })) return true } /** * 공간 삭제 — **이 공간의 트윈이 있으면 거절한다.** 그래서 트윈이 현장을 잃는 일은 없다. * * ── 왜 모양을 바꿨나 (2026-08-13) ──────────────────────────────────────── * 예전에는 `Boolean` 을 돌려주고 거절은 **한국어 문장을 냈다.** 두 가지가 틀렸다: * ① 서버가 화면의 말을 정했다 — 일본어·스페인어 사용자에게 한국어가 새어 나간다. * ② 오류를 낸 예외를 화면이 받지 않아 **아무 일도 없는 것처럼** 보였다. 사용자는 삭제를 눌렀고 * 화면은 그대로였다. 거절을 알리지 않고 하는 것은 실패보다 나쁘다. * 이제 `{ok, errorCode, errorParams}` 로 낸다 — 화면이 `twin.compose.del.` 로 말을 만든다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Delete a Space and its representations/areas. Refused while twins still live in it (a twin never loses its site this way). Returns {ok, errorCode, errorParams}.' }) async deleteTwinSpace(@Arg('spaceId') spaceId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id // 라이프사이클 가드 — 이 공간에 바인딩된 트윈 인스턴스가 있으면 삭제 차단(dangling 방지). // spaceId 컬럼(space #3) 로 직접 질의 — 전 인스턴스 로드+JS필터 제거(효율·1급 질의). const bound = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, spaceId } }) if (bound.length) { const twins = bound.map(i => i.name || i.instanceId) return { ok: false, errorCode: 'space-has-twins', errorParams: { spaceId, count: bound.length, twins: twins.slice(0, 5).join(', ') }, error: `space "${spaceId}" still has ${bound.length} twin(s) — remove or move them first (${twins.join(', ')})` } } const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId } }) if (space) { const reps = await getRepository(TwinSpaceRepresentation).find({ where: { domain: { id: domainId }, space: { id: space.id } } }) for (const rep of reps) await getRepository(TwinSpaceArea).delete({ domain: { id: domainId } as any, representation: { id: rep.id } as any }) await getRepository(TwinSpaceRepresentation).delete({ domain: { id: domainId } as any, space: { id: space.id } as any }) await getRepository(TwinArea).delete({ domain: { id: domainId } as any, space: { id: space.id } as any }) // 논리 area 도 함께 정리 } await getRepository(TwinSpace).delete({ domain: { id: domainId } as any, spaceId }) return { ok: true } } /** * 이미 있는 트윈을 이 현장으로 가져온다 — **소속 갱신**(이력 이전이 아니다). * * 저널·투영 행은 `instanceId` 로 매여 있어 트윈을 따라오고, 구역·표현은 공간의 것이라 남는다 * (근거·판정: `move-space.ts`). 그래서 이 뮤테이션이 쓰는 것은 두 곳뿐이다 — * 레지스트리의 `spaceId` 와 저장된 model 의 `spaceId`(커널·보드가 같은 공간을 가리켜야 한다). * 새 공간의 시간대로 `utcOffsetMinutes` 도 다시 파생한다(공간이 시간의 기준이다). * * `dryRun` 은 **같은 판정을 미리** 보여 준다 — 화면이 잃을 것을 먼저 말하고 사용자가 정한다. * 판정을 두 벌로 두면 미리보기와 실제가 어긋나므로 한 함수를 두 번 부른다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Move an existing twin into a space (co-locate it as another lens). The journal and projected rows follow the twin; areas and representations stay with the old space. dryRun returns the same judgement without writing. Returns {ok, warnings, errorCode}.' }) async moveTwinToSpace( @Arg('instanceId') instanceId: string, @Arg('spaceId') spaceId: string, @Ctx() context: ResolverContext, @Arg('dryRun', { nullable: true, description: 'Judge only — report refusal/warnings without moving.' }) dryRun?: boolean ): Promise { const domainId = context.state.domain.id const instRepo = getRepository(TwinInstance) const spaceRepo = getRepository(TwinSpace) const inst = await instRepo.findOne({ where: { domain: { id: domainId }, instanceId } }) const from = inst?.spaceId ? await spaceRepo.findOne({ where: { domain: { id: domainId }, spaceId: inst.spaceId } }) : null const to = await spaceRepo.findOne({ where: { domain: { id: domainId }, spaceId } }) /* 판정에 필요한 사실만 모아 넘긴다 — 판정 자체는 순수 모듈이 한다. */ const toAreaIds = to ? (await getRepository(TwinArea).find({ where: { domain: { id: domainId } as any, space: { id: to.id } as any } })).map(a => a.areaId) : [] const fromRepresentationCount = from ? await getRepository(TwinSpaceRepresentation).count({ where: { domain: { id: domainId } as any, space: { id: from.id } as any } }) : 0 const model: any = inst?.model ?? {} const areaRefs: (string | undefined)[] = (model.locations ?? []).map((l: any) => l?.parentId) const judgement = judgeMoveTwinSpace({ instanceId: inst?.instanceId, fromSpaceId: inst?.spaceId, toSpaceId: spaceId, toSpaceExists: !!to, purpose: (inst as any)?.purpose, running: !!inst && !!TwinEngine.runtime(domainId, instanceId), fromTimezone: from?.timezone ?? undefined, toTimezone: to?.timezone ?? undefined, areaRefs, toAreaIds, fromRepresentationCount }) if (judgement.refusal) { const r = judgement.refusal return { ok: false, error: r.message, errorCode: r.code, errorParams: r.params, warnings: [] } } if (dryRun) return { ok: true, dryRun: true, warnings: judgement.warnings } /* 두 곳을 함께 쓴다 — 레지스트리와 model 이 다른 공간을 가리키면 보드·커널이 갈라진다. */ const moved = { ...model, spaceId } const withTime = await TwinEngine.withSpaceTimeBase(moved, domainId, spaceId) await instRepo.save(instRepo.create({ ...(inst as any), spaceId, model: withTime })) /* * 감사 기록 — 소속을 바꾼 것은 사람의 결정이고, 나중에 "왜 이 트윈이 저 현장에 있나" 를 묻게 된다. * 기록이 실패해도 이전 결과는 돌려준다(감사가 본업을 막지 않는다). 다만 삼키지 않고 로그에 남긴다. */ try { const repo = getRepository(TwinAuditEvent) await repo.save( repo.create({ domain: context.state.domain, instanceId, commandType: 'twin.moveToSpace', accepted: true, origin: 'user', actorId: context.state.user?.id, args: { from: inst?.spaceId ?? null, to: spaceId, warnings: judgement.warnings.map(w => w.code) } } as any) ) } catch (err) { twinError(`[twin-space] move audit not recorded for "${instanceId}"`, err) } return { ok: true, warnings: judgement.warnings } } /** * 트윈을 현장에서 뗀다 — **어느 현장에도 속하지 않음**으로 되돌린다. * * 더하기·옮기기만 있으면 잘못 넣은 것을 되돌릴 수 없다. 떼도 저널·로케이션·설비는 트윈에 남고 * (`instanceId` 로 매여 있다), 구역·표현은 옛 현장에 남는다. 잃는 것은 **볼 무대**다 — * 판정이 그것을 말한다(`judgeDetachTwinSpace`). `dryRun` 은 같은 판정을 미리 낸다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Detach a twin from its space (it ends up on no site). The journal and projected rows stay with the twin; areas and representations stay with the space. dryRun returns the same judgement without writing. Returns {ok, warnings, errorCode}.' }) async detachTwinFromSpace( @Arg('instanceId') instanceId: string, @Ctx() context: ResolverContext, @Arg('dryRun', { nullable: true, description: 'Judge only — report refusal/warnings without detaching.' }) dryRun?: boolean ): Promise { const domainId = context.state.domain.id const instRepo = getRepository(TwinInstance) const inst = await instRepo.findOne({ where: { domain: { id: domainId }, instanceId } }) const from = inst?.spaceId ? await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: inst.spaceId } }) : null const fromRepresentationCount = from ? await getRepository(TwinSpaceRepresentation).count({ where: { domain: { id: domainId } as any, space: { id: from.id } as any } }) : 0 const judgement = judgeDetachTwinSpace({ instanceId: inst?.instanceId, fromSpaceId: inst?.spaceId, purpose: (inst as any)?.purpose, fromRepresentationCount, fromTimezone: from?.timezone ?? undefined }) if (judgement.refusal) { const r = judgement.refusal return { ok: false, error: r.message, errorCode: r.code, errorParams: r.params, warnings: [] } } if (dryRun) return { ok: true, dryRun: true, warnings: judgement.warnings } /* 두 곳을 함께 비운다 — 레지스트리와 model 이 어긋나면 어느 쪽이 사실인지 알 수 없다. 시간 기준(utcOffsetMinutes)은 **남긴다**: 지우면 알리지 않고 UTC 로 읽힌다(판정이 그렇게 말했다). */ const { spaceId: _dropped, ...model } = (inst?.model as any) ?? {} await instRepo.update({ id: (inst as any).id }, { spaceId: null as any, model }) try { const repo = getRepository(TwinAuditEvent) await repo.save( repo.create({ domain: context.state.domain, instanceId, commandType: 'twin.detachFromSpace', accepted: true, origin: 'user', actorId: context.state.user?.id, args: { from: inst?.spaceId ?? null, warnings: judgement.warnings.map(w => w.code) } } as any) ) } catch (err) { twinError(`[twin-space] detach audit not recorded for "${instanceId}"`, err) } return { ok: true, warnings: judgement.warnings } } /** * 공간 ↔ 트윈 ↔ 가시화 정합성 점검 — **사실을 모아 순수 판정에 넘긴다.** * * 세 축은 서로를 id 로만 가리키고 DB 가 그 참조를 지켜 주지 않는다. 트윈을 옮기거나 떼거나 원본을 * 다시 읽으면 아무 오류 없이 **가리킬 것이 없는 도형**과 **소속 없는 로케이션**이 남는다. 사람이 * 눈으로 대조하던 일을 기계가 한다(판정·근거: `space-integrity.ts`). * * `spaceId` 를 주면 그 현장만, 없으면 테넌트 전체를 본다. */ @Query(returns => [ScalarObject], { description: 'Check space ↔ twin ↔ visualization integrity: dangling site refs, registry/model mismatch, polygons binding areas or entities that are not there, primary representation, unbound board representations. Pass spaceId to check one site.' }) async twinSpaceIntegrity( @Ctx() context: ResolverContext, @Arg('spaceId', { nullable: true }) spaceId?: string ): Promise { const domainId = context.state.domain.id const spaceRows = await getRepository(TwinSpace).find({ where: { domain: { id: domainId } } }) const instRows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } }) const repRows = await getRepository(TwinSpaceRepresentation).find({ where: { domain: { id: domainId } }, relations: ['space'] }) const areaRows = await getRepository(TwinArea).find({ where: { domain: { id: domainId } }, relations: ['space'] }) const polyRows = await getRepository(TwinSpaceArea).find({ where: { domain: { id: domainId } }, relations: ['representation'] }) const areasBySpace: Record = {} for (const a of areaRows) { const sid = (a as any).space?.spaceId if (!sid) continue ;(areasBySpace[sid] ??= []).push(a.areaId) } /* * 폴리곤 바인딩의 실제 모양은 `{ groupId?, locationId?, locationIds? }` 다(지도 저작기 기준). * 여러 로케이션을 묶은 도형은 **각각을 따로** 본다 — 하나만 끊겨도 그 도형은 반쯤 거짓이 된다. */ const polysByRep: Record = {} for (const p of polyRows) { const rid = (p as any).representation?.id if (!rid) continue const b: any = p.binding ?? {} const list = polysByRep[rid] ??= [] if (b.groupId) list.push({ id: p.id, groupId: b.groupId }) const targets: string[] = [...(b.locationId ? [b.locationId] : []), ...(Array.isArray(b.locationIds) ? b.locationIds : [])] for (const t of targets) list.push({ id: p.id, targetKind: 'location', targetId: t }) } /* 보드 id 는 표현 config 가 들고 있다 — 존재 확인은 보드 서비스의 일이므로, 여기서는 표현이 가리키는 값이 있는지만 본다(없는 보드까지 보려면 board-service 를 물어야 한다: 아래 주석). */ const boardIds = repRows.map(r => (r.config as any)?.boardId).filter(Boolean) as string[] const findings = checkSpaceIntegrity({ spaces: spaceRows.map(s => ({ spaceId: s.spaceId, primaryRepresentationId: s.primaryRepresentationId })), instances: instRows.map(i => { const model: any = i.model ?? {} /* * 어휘가 맞는지는 **실행 중인 트윈만** 답할 수 있다 — 커널이 무엇을 버렸는지는 런타임의 사실이다. * 정지한 트윈은 `undefined`(모른다)로 둔다. 저널을 다시 계산해 알아내지는 않는다: 이 질의는 * 테넌트 전체를 훑으므로 트윈마다 폴드를 걸면 정합성 점검이 가장 무거운 질의가 된다. */ const live: any = TwinEngine.isRunning(domainId, i.instanceId) ? TwinEngine.snapshot(domainId, i.instanceId) : undefined return { instanceId: i.instanceId, spaceId: i.spaceId, modelSpaceId: model.spaceId, purpose: (i as any).purpose, locationIds: (model.locations ?? []).map((l: any) => l?.id).filter(Boolean), equipmentIds: (model.equipment ?? []).map((e: any) => e?.id).filter(Boolean), areaRefs: (model.locations ?? []).map((l: any) => l?.parentId), ...(Array.isArray(live?.unhandled) ? { unhandled: live.unhandled } : {}) } }), representations: repRows.map(r => ({ id: r.id, spaceId: (r as any).space?.spaceId ?? '', type: r.type, boardId: (r.config as any)?.boardId ?? null, polygons: polysByRep[r.id] ?? [] })), areasBySpace, boardIds }) const scoped = spaceId ? findings.filter(f => f.spaceId === spaceId || (!f.spaceId && !spaceId)) : findings return sortFindings(scoped) } /** * 기계적으로 고칠 수 있는 어긋남만 고친다 — **사람의 판단이 필요한 것은 손대지 않는다.** * * 지금 고치는 것은 하나다: **대표 표현 포인터**. 공간이 없는 표현 id 를 가리키고 있으면, 행의 * `isPrimary` 가 켜진 표현(없으면 첫 표현)으로 다시 가리키고, 표현이 없으면 비운다. 근거가 있고 * 답이 하나뿐인 부류라 자동으로 고쳐도 사실을 바꾸지 않는다. * * 폴리곤이 없는 구역·개체를 가리키는 것은 **고치지 않는다.** 그 도형을 지워야 하는지, 트윈을 다시 * 데려와야 하는지, 구역을 다시 그려야 하는지는 사람이 정할 일이다 — 기계가 고르면 사용자가 그린 * 도형이 오류 없이 사라진다. */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Repair mechanically fixable space integrity findings (currently: a primary representation pointer that points nowhere). Dangling polygon bindings are left alone — that is a human decision. Returns {repaired:[{spaceId, code}], skipped}.' }) async repairTwinSpaceIntegrity( @Ctx() context: ResolverContext, @Arg('spaceId', { nullable: true }) spaceId?: string ): Promise { const domainId = context.state.domain.id const spaceRepo = getRepository(TwinSpace) const repRepo = getRepository(TwinSpaceRepresentation) const spaces = await spaceRepo.find({ where: spaceId ? { domain: { id: domainId }, spaceId } : { domain: { id: domainId } } }) const repaired: any[] = [] for (const space of spaces) { const reps = await repRepo.find({ where: { domain: { id: domainId } as any, space: { id: space.id } as any }, order: { seq: 'ASC' } }) const pointer = (space.primaryRepresentationId ?? '').trim() if (pointer && reps.some(r => r.id === pointer)) continue /* 이미 옳다 */ const primary = reps.find(r => r.isPrimary) ?? reps[0] const next = primary?.id ?? null if (pointer === (next ?? '')) continue await spaceRepo.save(spaceRepo.create({ ...space, primaryRepresentationId: next })) if (primary) for (const r of reps) if (!!r.isPrimary !== (r.id === primary.id)) await repRepo.save(repRepo.create({ ...r, isPrimary: r.id === primary.id })) repaired.push({ spaceId: space.spaceId, code: 'primary-representation-missing', from: pointer || null, to: next }) } return { ok: true, repaired, count: repaired.length } } /* ── Area(논리 구역, 표현 무관·공유·계층) — area 단일화 space #2 ─────── */ @Query(returns => [ScalarObject], { description: 'List logical areas (representation-agnostic hierarchy) of a space. (area unification)' }) async twinAreas(@Arg('spaceId') spaceId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id const rows = await getRepository(TwinArea).find({ where: { domain: { id: domainId }, space: { spaceId } }, order: { createdAt: 'ASC' } }) return rows.map(a => ({ areaId: a.areaId, name: a.name, type: a.type, parentId: a.parentId, layout: a.layout })) } @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Create/update a logical area (shared across representations). (area unification)' }) async saveTwinArea( @Arg('spaceId') spaceId: string, @Arg('areaId') areaId: string, @Arg('patch', type => ScalarObject) patch: any, @Ctx() context: ResolverContext ): Promise { const domainId = context.state.domain.id const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId } }) if (!space) throw new Error(`space "${spaceId}" not found`) const repo = getRepository(TwinArea) const existing = await repo.findOne({ where: { domain: { id: domainId }, space: { id: space.id }, areaId } }) await repo.save( repo.create({ ...(existing ?? {}), domain: { id: domainId } as any, space: { id: space.id } as any, areaId, name: patch?.name ?? existing?.name ?? areaId, type: patch?.type ?? existing?.type, parentId: patch?.parentId !== undefined ? patch.parentId : existing?.parentId, layout: patch?.layout ?? existing?.layout }) ) return true } /* * 참조기반 회수(reference-counted) — area 는 참조가 남아 있으면 삭제 차단, 고아면 제거. * 참조: 자식 area(parentId) · 표현별 폴리곤(TwinSpaceArea binding.groupId/areaId). * (노드/엔티티 담김·drillTo 참조는 P2 areaId 배선 후 확장.) */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Delete a logical area only if unreferenced (reference-counted). Throws if referenced. (area unification)' }) async deleteTwinArea(@Arg('spaceId') spaceId: string, @Arg('areaId') areaId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId } }) if (!space) return false // 참조 1: 자식 area const children = await getRepository(TwinArea).count({ where: { domain: { id: domainId }, space: { id: space.id }, parentId: areaId } }) // 참조 2: 표현별 폴리곤(이 공간의 표현들에 이 area 를 그린 것). binding.groupId/areaId 는 JSON → 로드 후 JS 필터(멀티DB). const reps = await getRepository(TwinSpaceRepresentation).find({ where: { domain: { id: domainId }, space: { id: space.id } } }) let polygonRefs = 0 for (const rep of reps) { const polys = await getRepository(TwinSpaceArea).find({ where: { domain: { id: domainId }, representation: { id: rep.id } } }) polygonRefs += polys.filter(p => (p.binding as any)?.groupId === areaId || (p.binding as any)?.areaId === areaId).length } if (children > 0 || polygonRefs > 0) { throw new Error(`deleteArea: area "${areaId}" cannot be deleted due to existing references (children: ${children}, polygonRefs: ${polygonRefs}). Remove references first.`) } await getRepository(TwinArea).delete({ domain: { id: domainId } as any, space: { id: space.id } as any, areaId }) return true } /* ── Representation ─────────────────────────────────────────────── */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Create/update a representation (map|image|model). Returns {id}.' }) async saveSpaceRepresentation( @Arg('spaceId') spaceId: string, @Arg('rep', type => ScalarObject) rep: any, @Ctx() context: ResolverContext ): Promise { const domainId = context.state.domain.id const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId } }) if (!space) return { ok: false, error: `space not found: ${spaceId}` } const repo = getRepository(TwinSpaceRepresentation) const existing = rep?.id ? await repo.findOne({ where: { id: rep.id } }) : null /* * `role` 은 닫힌 값이다 — 모르는 값을 그냥 저장하면 화면이 그것을 어느 쪽으로도 그리지 못하고, * 그 표현이 목록에서 오류 없이 사라진다. 비어 있는 것은 `index` 를 뜻하므로 그대로 둔다. */ const role = rep.role == null || String(rep.role).trim() === '' ? undefined : String(rep.role).trim() if (role && !(REPRESENTATION_ROLES as readonly string[]).includes(role)) { return { ok: false, error: `unknown representation role "${role}" — one of: ${REPRESENTATION_ROLES.join(', ')}` } } /* 묶는 이름은 자유 문자열이다. 다만 공백만 있는 값은 「묶이지 않음」과 같으므로 비운다. */ const group = rep.group == null || String(rep.group).trim() === '' ? undefined : String(rep.group).trim() const saved = await repo.save( repo.create({ ...(existing ?? {}), domain: { id: domainId } as any, space: { id: space.id } as any, type: rep.type, name: rep.name, seq: rep.seq ?? 0, isPrimary: !!rep.isPrimary, config: rep.config, group: group ?? null, role: role ?? null } as Partial) ) return { ok: true, id: saved.id } } @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Delete a representation and its areas.' }) async deleteSpaceRepresentation(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id await getRepository(TwinSpaceArea).delete({ domain: { id: domainId } as any, representation: { id } as any }) await getRepository(TwinSpaceRepresentation).delete({ domain: { id: domainId } as any, id } as any) return true } @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Set the primary representation of a space.' }) async setPrimaryRepresentation(@Arg('spaceId') spaceId: string, @Arg('repId') repId: string, @Ctx() context: ResolverContext): Promise { const domainId = context.state.domain.id const spaceRepo = getRepository(TwinSpace) const space = await spaceRepo.findOne({ where: { domain: { id: domainId }, spaceId } }) if (!space) return false await spaceRepo.save(spaceRepo.create({ ...space, primaryRepresentationId: repId })) const repRepo = getRepository(TwinSpaceRepresentation) const reps = await repRepo.find({ where: { domain: { id: domainId }, space: { id: space.id } } }) for (const rep of reps) if (!!rep.isPrimary !== (rep.id === repId)) await repRepo.save(repRepo.create({ ...rep, isPrimary: rep.id === repId })) return true } /* ── Area (map/image 폴리곤) ────────────────────────────────────── */ @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => ScalarObject, { description: 'Create/update a polygon area (geometry + drillTo model + binding). Returns {id}.' }) async saveSpaceArea( @Arg('representationId') representationId: string, @Arg('area', type => ScalarObject) area: any, @Ctx() context: ResolverContext ): Promise { const domainId = context.state.domain.id const repo = getRepository(TwinSpaceArea) const existing = area?.id ? await repo.findOne({ where: { id: area.id } }) : null /* drillTo/binding 은 선택적 링크 — 클라가 해제(undefined)로 보내면 JSON 에서 키가 빠져 TypeORM 이 "변경 없음"으로 * 무시하므로 기존 값이 남는다. null 로 강제해 명시적으로 컬럼을 비운다(저장은 늘 전체 필드를 보냄 → 덮어쓰기 안전). */ const saved = await repo.save(repo.create({ ...(existing ?? {}), domain: { id: domainId } as any, representation: { id: representationId } as any, name: area.name, type: area.type, geometry: area.geometry, drillTo: area.drillTo ?? null, binding: area.binding ?? null, style: area.style })) return { ok: true, id: saved.id } } @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Delete a polygon area.' }) async deleteSpaceArea(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise { await getRepository(TwinSpaceArea).delete({ domain: { id: context.state.domain.id } as any, id } as any) return true } }