import { Arg, Ctx, Directive, Float, Mutation, Query, Resolver } from 'type-graphql' import { getRepository } from '@things-factory/shell' import { TwinTarget } from './twin-target.js' import { TWIN_METRIC, isTwinMetric } from '../../engine/kpi-target.js' /* * 운영 목표 조회·편집 — **현장 관리자가 트윈 화면에서** 고친다(사용자 결정 2026-08-02). * * 경영 KPI 관리메뉴가 아니라 여기인 이유: 목표가 숫자 옆에 있어야 맥락이 살고, 운영 임계는 자주 * 조정된다. 조직 단위 기간 평가는 `@things-factory/kpi` 의 일이고 이쪽으로 베껴 오지 않는다. */ /* * **쓰기는 막고 읽기는 연다** (2026-08-06 · ADR-0027). * * 이 리졸버는 트윈 리졸버 중 **유일하게** 권한 게이트를 갖는데, 처음에는 우회 표시가 없었다 * (`@privilege(category:"twin", privilege:"query")`). 그러면 `twin/query`·`twin/mutation` 권한을 * 실제로 부여받은 사용자만 쓸 수 있는데 — **저장소 어디에도 그 권한을 만드는 곳이 없다.** 결과는 * 도메인 소유자도 슈퍼유저도 목표를 걸 수 없는 상태였다: 성과 화면의 목표 버튼이 "Unauthorized" 로 * 떨어지고, 이 관점의 최상위 기능(§5 목표 프레임)이 **아무도 쓸 수 없는 채로** 놓여 있었다. * * 그래서 프레임워크의 통용 관용구를 따른다 — 가장 가까운 형제인 `@things-factory/kpi`(목표·지표 관리)가 * 74곳 전부 `domainOwnerGranted: true, superUserGranted: true` 를 쓴다. 같은 성격의 조작(무엇을 잘한 * 것으로 볼지 정하는 일)이므로 같은 자세를 쓴다: **현장을 소유한 사람은 바로 쓰고**, 다른 사람에게 * 맡기려면 `twin` 권한을 부여한다. * * 이제 트윈의 **모든 변경**에 같은 게이트가 있다(ADR-0027) — 처음에는 이 리졸버 하나만 게이트를 갖는 * 반쪽이었다. 조회 게이트는 걷었다: 같은 판정이 `twinKpi` 로 이미 열려 있어 지키는 것이 없었다. */ @Resolver() export class TwinTargetResolver { /* 조회에는 게이트를 두지 않는다 — 같은 판정이 `twinKpi.targets` 로 이미 열려 있어(그쪽은 게이트가 없다) 여기만 막으면 지키는 것 없이 화면만 깨진다. 읽기의 경계는 **테넌트 소속**이다. */ @Query(returns => [String], { description: 'The metric ids a twin target may refer to. Fixed in one place so a target and the fact it judges can never drift into two names.' }) async twinTargetMetrics(): Promise { return Object.keys(TWIN_METRIC) } @Query(returns => [TwinTarget], { description: 'Operational targets in scope — site-wide targets for the space plus targets bound to the given instance. Callers judge with the narrower one when both exist.' }) async twinTargets( @Ctx() context: ResolverContext, @Arg('spaceId', { nullable: true }) spaceId?: string, @Arg('instanceId', { nullable: true }) instanceId?: string ): Promise { const domainId = context.state.domain.id const rows = await getRepository(TwinTarget).find({ where: { domain: { id: domainId } } }) return rows.filter(r => (spaceId && r.spaceId === spaceId) || (instanceId && r.instanceId === instanceId)) } @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Create or update one operational target. The metric must be one of twinTargetMetrics — an unknown name is rejected rather than stored, because a target nobody can evaluate reads as "no target" and hides the gap.' }) async saveTwinTarget( @Arg('metric') metric: string, @Arg('target', type => Float) target: number, @Ctx() context: ResolverContext, @Arg('spaceId', { nullable: true }) spaceId?: string, @Arg('instanceId', { nullable: true }) instanceId?: string, @Arg('axis', { nullable: true }) axis?: string, @Arg('axisKey', { nullable: true }) axisKey?: string ): Promise { if (!isTwinMetric(metric)) throw new Error(`unknown twin metric "${metric}" — see twinTargetMetrics`) if (!Number.isFinite(target)) throw new Error('target must be a finite number') if (!spaceId && !instanceId) throw new Error('a target must be scoped to a space or an instance') /* 축만 있고 값이 없으면 **어디에 거는 목표인지 알 수 없다** — 알리지 않고 전체 목표로 만들지 않는다 (그러면 사용자는 구역 목표를 걸었다고 믿는데 전체가 바뀐다). */ if (axis && !axisKey) throw new Error('axisKey is required when axis is given') if (!axis && axisKey) throw new Error('axis is required when axisKey is given') const domainId = context.state.domain.id const repo = getRepository(TwinTarget) const existing = await repo.findOne({ where: { domain: { id: domainId }, spaceId: spaceId ?? null, instanceId: instanceId ?? null, metric, axis: axis ?? null, axisKey: axisKey ?? null } as any }) await repo.save( repo.create({ ...(existing ?? {}), domain: { id: domainId } as any, spaceId: spaceId ?? null as any, instanceId: instanceId ?? null as any, axis: axis ?? null as any, axisKey: axisKey ?? null as any, metric, target, ...(existing ? { updater: context.state.user } : { creator: context.state.user, updater: context.state.user }) }) ) return true } @Directive('@privilege(category: "twin", privilege: "mutation", domainOwnerGranted: true, superUserGranted: true)') @Mutation(returns => Boolean, { description: 'Remove an operational target. Removing a target removes the judgement, not the fact — the metric keeps being reported without a verdict.' }) async deleteTwinTarget( @Arg('metric') metric: string, @Ctx() context: ResolverContext, @Arg('spaceId', { nullable: true }) spaceId?: string, @Arg('instanceId', { nullable: true }) instanceId?: string, @Arg('axis', { nullable: true }) axis?: string, @Arg('axisKey', { nullable: true }) axisKey?: string ): Promise { const domainId = context.state.domain.id const repo = getRepository(TwinTarget) /* * **찾아서 그 행만 지운다.** 예전에는 조건 객체를 `delete()` 에 그대로 넘겼는데 **아무 행도 * 안 지워지면서 `true` 를 돌려줬다.** 사용자는 목표를 지웠다고 믿고 화면을 떠나는데 판정은 그대로 * 남는다(실화면에서 그렇게 당했다). * * 무엇이 조건을 깨뜨렸는지는 단정하지 않는다 — 이 조건에는 `domain` 관계 객체와 `null` 값들이 * 함께 들어 있었고(`instanceId`·`axis`·`axisKey`), 둘 다 `delete()` 의 조건 해석에서 흔한 함정이다. * 같은 관계-객체 패턴을 쓰는 `resetJournal` 은 실제로 잘 지우므로 관계 객체 하나만으로 설명되지 * 않는다. `null` 조건이 `IS NULL` 이 아니라 `= NULL` 로 나갔을 가능성이 크다. * * 그래서 조건을 고쳐 맞추는 대신 **행을 찾아 id 로 지운다**: 저장 경로와 **같은 조회 규칙**을 * 쓰므로 규칙이 갈라지지 않고(`findOne` 은 `null` 을 제대로 다룬다), 지우는 대상이 정확히 한 * 행이라 넓게 지울 위험도 없다. */ const row = await repo.findOne({ where: { domain: { id: domainId }, spaceId: spaceId ?? null, instanceId: instanceId ?? null, axis: axis ?? null, axisKey: axisKey ?? null, metric } as any }) /* 지울 것이 없으면 **없었다고 답한다.** 지우지 않고 true 를 주면 위와 같은 거짓말이 된다. */ if (!row) return false await repo.delete(row.id) return true } }