FeaturesAudit (감사 로그)HQ Audit (/audit/hq)

HQ Audit — /audit/hq (운영사 통합 본사 audit 화면)

SPEC #081 · #068 F4 마감. 시안 부재 → apps/space 본사 audit(/admin/audit, SPEC #067) idiom 미러 + hqName 컬럼 추가. [[feedback_design_only_from_handoff]] 준수.

Overview

운영사(OPERATOR)가 모든 본사의 hq_audit_log 를 한 화면에서 통합 조회하는 백오피스 페이지. 본사 매니저(HQ_MANAGER)는 자기 본사 audit 만 보지만(apps/space /admin/audit, SPEC #067 — WHERE hq_id 강제), 운영사는 전체 본사 합집합 + hqName 컬럼으로 어느 본사의 audit 인지 식별 한다. append-only(편집/삭제 불가).

시안 정합: 시안 부재 → 기존 운영사 audit/actions(SPEC #026·#031-D) 헤더 idiom(PageHeader

  • AuditTabs) + 본사 audit(apps/space SPEC #067) row idiom(액션 배지·행위자·임퍼소네이션 보조 줄) 을 미러한다. 임의 시각/배치 발명 0.

Route & Endpoint

항목
FE 라우트/audit/hq (운영사 백오피스)
BE endpointGET /api/v1/admin/audit/hqlistOperatorHqAudit (OPERATOR-only)
응답OperatorHqAuditListResponse{items, page, size, total}
정렬occurred_at DESC, id ASC (서버 고정 — 결정적)
페이지 크기20 (PAGE_SIZE)

본사 view 와의 차이 (SPEC #067 vs #081)

측면본사 view (/admin/audit, #067)운영사 view (/audit/hq, #081)
토큰HQ_MANAGEROPERATOR
WHERE 가드WHERE hq_id = :hqId 강제없음 (전체 본사 합집합)
본사 격리자기 본사만모든 본사 — 정책 결정 (D6)
응답 행HqAuditItemOperatorHqAuditItem (= HqAuditItem 미러 + hqName)
본사 필터없음 (자기 본사뿐)hqId? 옵션 — 특정 본사만 좁힘
검색 qtargetLabel + detail 부분일치동일
임퍼소네이션 표기row 보조 줄 ↩ impersonatedByEmail + “운영자 위장” pill동일
CSV exportexportHqAuditDispatches (#070)exportOperatorHqAudit (#088)
행위자 selectuseListHqAuditActors (#069)F2 후속 (현재 UUID 자유 입력)

UI

  • <PageHeader> 제목 “감사 로그 · 본사 audit” + 부제 “모든 본사의 감사 행 · 총 N건”.
  • 감사 영역 탭(SPEC #081 §D9) — 상단에 <AuditTabs> 3종(임퍼소네이션 | 운영자 액션 | 본사 audit). 본 페이지는 3번째 탭. aria-current="page" 활성 표시.
  • 단일 테이블 — 발생 시각(KST) · 본사명(hqName, 신규) · 액션(StatusPill) · 대상(타입 배지
    • 라벨) · 행위자(이메일·역할 배지·임퍼소네이션 보조 줄) · 상세. 한 행에 모두 표시 → 드릴다운 없음.
  • 필터 바 — 공용 @linkmusic/ui ListToolbar(controlled) — 검색(q, 내장 input) + 발생 기간 from/to(date) + 액션 select(HqAuditAction 15종 — Object.values 자동, #108·#078·#144 포함) + 대상 유형 select(HqAuditTargetType). 본사 필터(hqId UUID 자유 입력)는 ListToolbar 의 select/date 차원에 맞지 않아 별도 행 으로 분리(F2 에서 select 전환). 적용 버튼 → applied state 갱신(draft↔applied 분리) → 1페이지 리셋.
  • 페이지네이션 — 공용 ListPagination (page/totalPages/total + 이전/다음).
  • 빈 상태total === 0 진짜 0건(필터 유무 분기 — 있으면 [필터 초기화] 보조) vs total > 0 && items.length === 0 out-of-range page([첫 페이지로]).

시각 표시 (KST)

occurredAt(backend UTC)은 공용 helper @/lib/format formatKstDateTime 으로 KST 시각으로 표시(SSR/CSR 머신 TZ 무관). 기간 필터 from/to<input type=date> date-only → backend 호출 시 KST(+09:00) 경계 정규화(from=00:00:00.000·to=23:59:59.999 inclusive).

Implementation

Page (server component, shell only)

apps/admin/src/app/(protected)/audit/hq/page.tsx<AuditHqClient /> 만 mount. protected layout 이 이미 OPERATOR 인가 가드 + BFF 토큰 경유를 보장하므로 server-side fetch·refresh-aware 가드는 두지 않는다(actions/impersonation 의 server-driven 패턴과 의도적 분기 — 본 통합 view 는 deep-link 공유 가치가 낮아 client state 가 충분).

Client

audit-hq-client.tsx — generated useListOperatorHqAudit 훅 직접 호출(BFF mutator apiFetch 경유 — HttpOnly 토큰 보존). draft↔applied 분리(폼 입력은 draft, fetch 에는 applied). 액션·대상 유형 enum 옵션은 generated enum Object.values 자동 확장(후속 enum 추가 시 select UI 가 늘어남). 한글 라벨/톤은 Record<enum, ...> 전수 강제(누락 컴파일 에러).

audit-tabs.tsx(SPEC #081 수정) — 3번째 탭 본사 audit(/audit/hq) 추가. 사이드바 “감사 로그” 항목은 단일 항목으로 유지하고 세 audit 페이지가 탭을 공유한다(기존 두 페이지에 본 슬라이스의 페이지가 합류).

States & Edge Cases

상태처리
audit 0건”아직 기록된 본사 감사 로그가 없습니다” 빈 상태 (total === 0, 필터 없음)
필터 0건”조건에 맞는 감사 로그가 없습니다” + [필터 초기화] (total === 0, 필터 있음)
out-of-range page”이 페이지에 표시할 감사 로그가 없습니다” + [첫 페이지로] (total > 0, items 0)
5xx / 네트워크 실패Banner danger “서버에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.”
401mutator 가 /login?next=... 풀 리다이렉트(브라우저 한정·루프 가드)
403Banner danger “이 페이지를 볼 권한이 없습니다.”
detail 없음/blank상세 셀
잘못된 action/targetType 입력sanitize → "" 전체 폴백 (select 옵션과 항상 일치)
q 길이 초과(>100)client maxLength=100 + apply 시 slice(0,100) 가드 → backend 400 회피
잘못된 hqId UUIDbackend @UUID 검증 400 → Banner 흡수 (F2 select 전환 시 차단)

CSV 내보내기 (SPEC #088)

운영사 통합 본사 audit 행을 현재 적용된 필터 전체 매칭으로 CSV 다운로드한다. 화면은 페이지네이션 (20)이지만 CSV 는 같은 필터의 전체 집합을 한 파일로 — 회계·감사·법무 실수요. #048(운영자 액션)· #070(본사 송출)·#074(임퍼소네이션) 와 같은 패턴으로 도착 → CSV export 4종 완결.

Endpoint

GET /api/v1/admin/audit/hq/export (exportOperatorHqAudit, OPERATOR-only) — 200 text/csv; charset=utf-8(UTF-8 BOM + RFC4180) + Content-Disposition: attachment; filename*=UTF-8''본사통합감사_{yyyyMMdd_HHmmss}.csv(RFC5987 percent-encoded · 한글 파일명) + X-Export-Truncated: true|false 헤더(상한 신호). query 는 listOperatorHqAudit 와 동일하되 page/size 만 제외 — from?·to? ISO-8601 date-time · hqId? · action? · targetType? · q?(≤100). 정렬 occurred_at DESC, id ASC 서버 고정(list 와 동일 결정적).

컬럼 (8종, 한국어 헤더)

#헤더비고
1발생시각KSToccurredAtAsia/Seoul (yyyy-MM-dd HH:mm:ss)
2본사명hqName (어느 본사 audit 인지 식별)
3액션본사 audit 15종 한국어 라벨 매핑(송출/생성/수정/삭제/송출 취소/송출 원격중단/반복 예약 등록/반복 예약 취소/티켓 생성/티켓 댓글/티켓 상태 변경/티켓 우선순위 변경/매장 등록/매장 정보 수정/매장 지역 변경)
4대상 유형targetType (TTS_ANNOUNCEMENT → “안내방송”)
5대상 라벨targetLabel (없으면 빈 칸)
6행위자 이메일actorEmail
7행위자 역할actorRole 라벨(본사 매니저/운영자 위장)
8상세detail (없으면 빈 칸)

상한 · CSV injection 방어

  • 상한 EXPORT_MAX=50000 행(베타 규모 충분). 초과 시 50,000+1 peek-ahead 감지 → 50,000 row 만 emit + 응답 헤더 X-Export-Truncated: true → 클라이언트가 잘림 경고 배너 노출 (“결과가 50,000행 으로 잘렸습니다. 필터를 좁힌 뒤 다시 시도해 주세요.”).
  • CSV injection 방어 — 공용 AuditCsvWriter=·+·-·@·탭·CR 시작 셀에 ' prefix (SPEC #048/#070/#074 와 동일 모듈 재사용; new method writeOperatorHqAuditCsv).

FE 다운로드 패턴 (generated 훅 우회)

generated orval hook 미사용apiFetch mutator 가 모든 응답을 res.text() → JSON 파싱이라 text/csv 응답에 부적합. 공용 helper apps/admin/src/lib/csv-export.ts downloadCsvFromBackend( path, query, fallbackFilename) 가 우회한다(#048/#070/#074 와 동일 모듈).

  1. 브라우저에서 BFF (/api/backend/v1/admin/audit/hq/export?...) 로 직접 fetch (catch-all route handler 가 arrayBuffer() passthrough + Content-Type/Content-Disposition 헤더 보존). 새 라우트 0.
  2. res.blob()URL.createObjectURL<a download> 부착·클릭·제거 → 다음 tick 에 revokeObjectURL(Safari 다운로드 보호).
  3. 401 감지 시 /login?next={pathname+search} 풀 리다이렉트(mutator 동작 미러, 필터 search 보존).

/audit/hq PageHeader.primary 슬롯에 [CSV 내보내기] 버튼(lucide:Download 아이콘, secondary variant, sm). 진행 중 disabled + aria-busy="true" + 라벨 “다운로드 중…”. 잘림 시 Banner variant="warn" + [닫기], 실패 시 Banner variant="danger" + [닫기]AuditTabs 와 본문 사이에 배치(#074 미러).

보낼 필터 (applied 값)

화면 테이블이 보여주는 것과 일치하도록 draft(폼 입력) 가 아닌 applied 필터(appliedFrom· appliedTo·appliedHqId·appliedAction·appliedTargetType·appliedQ)를 보낸다. <input type=date> date-only 는 KST(+09:00) 경계로 정규화한다(from=00:00:00.000·to=23:59:59.999 inclusive — list 와 동일 규칙). generated ExportOperatorHqAuditParamsactorAccountId 는 본 슬라이스에서 노출하지 않으므로(F2 행위자 select 후속) 항상 undefined(keyof 전수 강제 가드용).

Roadmap (후속)

  • F1 CSV 내보내기SPEC #088 도착.
  • F2 본사 필터 selectlistOperatorHqOptions (또는 기존 본사 카탈로그 활용) 자동완성 select 로 UUID 자유 입력 대체.
  • 실시간 audit (#067 F6) · 보존 정책 (#067 F7) 은 본사·운영사 공통 후속.
  • PDF · 자동 스케줄 (CSV export 4종 공통 후속).

References

  • SPEC #081 (운영사 통합 본사 audit) · SPEC #088 (CSV 내보내기) · #068 F4 (마감) · #067 (본사 audit 백본 — row idiom 원본) · #048 (운영자 액션 CSV — 공용 helper 원본) · #070 (본사 audit CSV) · #074 (임퍼소네이션 CSV — 패턴 직접 미러 원본) · #026 · #031-D (운영사 audit 헤더 idiom)
  • apps/admin/src/app/(protected)/audit/hq/ (page.tsx · audit-hq-client.tsx) · apps/admin/src/lib/csv-export.ts · apps/admin/src/app/(protected)/audit/audit-tabs.tsx (탭 확장)
  • 시안 부재 — apps/space hq-audit-client.tsx row idiom + apps/admin actions/impersonation 헤더 idiom 미러