import { Args, Ctx, Query, Resolver } from 'type-graphql' import { ListParam, getQueryBuilderFromListParams, getRepository, ScalarObject } from '@things-factory/shell' import { TwinAuditEvent } from './twin-audit-event.js' /* * 승인 기록 조회 — **감사의 기본 질문에 답한다.** * * "요즘 무슨 조치가 나갔나" → 최근순 목록 * "김현장이 승인한 것 전부" → 행위자 검색(인덱스 있음) * "AI 가 제안한 것만" → origin 필터 * "거절된 시도" → accepted 필터 * * 저널에 섞여 있을 때는 이 중 어느 것도 못 했다 — 행위자가 payload JSON 안이라 인덱스를 걸 수 없었다. */ /** 시각 필터 값을 날짜로 — 못 읽는 값은 그대로 둔다(지어내지 않는다). */ function toDateValue(v: any): any { const one = (x: any) => { if (x instanceof Date) return x const t = typeof x === 'string' ? Date.parse(x) : NaN return Number.isNaN(t) ? x : new Date(t) } return Array.isArray(v) ? v.map(one) : one(v) } @Resolver() export class TwinAuditQuery { /* 조회 게이트는 달지 않는다 — 이 저장소의 규약이고(mutation-gate 검사가 정본을 지킨다), 도메인 필터가 테넌트 경계를 이미 지킨다. 테넌트를 넘어 읽는 계기판만 OPS_GATE 를 쓴다. */ @Query(returns => ScalarObject, { nullable: true, description: 'List twin command audit records (who approved what, and what the kernel answered) with the standard list contract, plus the total match count.' }) async twinAuditList( /* 표준 목록 계약은 `@Args` 로 펼쳐 받는다 — `@Arg` 로 받으면 type-graphql 이 입력 타입을 결정하지 못해 **스키마 조립 자체가 실패**한다(서버가 아예 안 뜬다). */ @Args(type => ListParam) params: ListParam, @Ctx() context: ResolverContext ): Promise { const domainId = context.state.domain?.id if (!domainId) return { items: [], total: 0 } const effective: ListParam = { ...(params ?? ({} as ListParam)), /* 기본 정렬 — 최근 것부터. 감사는 "요즘" 에서 시작한다. */ sortings: params?.sortings?.length ? params.sortings : [{ name: 'eventTime', desc: true }], /* 시각 필터는 날짜로 옮겨서 넘긴다 — 문자열 그대로면 알리지 않고 0건이 된다(저널과 같은 이유). */ filters: (params?.filters ?? []).map(f => (f.name === 'eventTime' ? { ...f, value: toDateValue(f.value) } : f)) } const qb = getQueryBuilderFromListParams({ repository: getRepository(TwinAuditEvent), params: effective, domain: context.state.domain, alias: 'a', /* * `searchables` 는 전부 **실컬럼**이다 — 프레임워크 질의 빌더는 여기 없는 컬럼의 LIKE 를 * 경고 후 무시한다(인덱스 없는 전체 스캔 방지). 행위자로 찾는 것이 이 테이블을 구분한 이유다. */ searchables: ['actorEmail', 'actorName', 'commandType', 'origin', 'errorCode', 'reason'], sortables: ['eventTime', 'commandType', 'actorEmail', 'origin', 'accepted'], /* 감사 목록은 사람이 읽는 속도로 본다 — 크게 퍼 오는 것이 이득이 아니다. */ defaultLimit: 50, maxLimit: 200 }) const [items, total] = await qb.getManyAndCount() return { items, total } } }