API 카탈로그Request · Response DTOs

Request · Response DTOs (전체 카탈로그)

OpenAPI 자동 생성. 수기 정의 금지 ([[feedback-15]]).

Overview

이 페이지는 주요 DTO 의 reference. 실제 진실은 OpenAPI (/v3/api-docs) 와 generated TS schema (packages/api-client/src/generated/schemas/) + orval endpoints (packages/api-client/src/generated/endpoints/).

#028 (BE v0.20.2) — backend 가 비어 있을 수 있는 필드에 @Schema(nullable=true) 를 명시해 generated 타입이 T | null 로 바뀐 항목이 있다(아래 #028 nullable 표기 참조). FE 는 ?? undefined/?? "—"/== null 가드로 처리(의미 유지). 또 operationId 유니크화로 generated 훅/Params/QueryKey 이름이 endpoint 의미를 반영한다 (Endpoints operationId 매핑 참조).

Auth DTOs

LoginRequest

{ email: string; password: string }

AuthResponse

/api/v1/auth/login · /refresh · /change-password 공통 응답.

{
  accessToken: string;
  accessExpiresInSeconds: number;  // access 토큰 수명(초, default 900 = 15min)
  refreshToken: string;
  refreshExpiresAt: string;        // refresh 만료 ISO 8601 절대 시각
  accountId: string;
  role: "OPERATOR" | "HQ_MANAGER" | "STORE_MANAGER";
  passwordMustChange: boolean;
}

RefreshRequest / LogoutRequest

{ refreshToken: string }

MeResponse (#003 + #005 flag + #016 hqName)

{
  id: string;            // UUID
  email: string;
  name?: string;
  role: Role;
  hqId?: string;         // HQ_MANAGER 만
  storeId?: string;      // STORE_MANAGER 만
  passwordMustChange: boolean;
  termsRequiresAgreement: boolean;   // SPEC #005
  privacyRequiresAgreement: boolean;
  hqName?: string;       // SPEC #016 — HQ_MANAGER 만 값(hqId→Hq.name). 그 외 omit
}

hqName 은 실 HQ_MANAGER 가 /admin 본사 모드 셸 진입 시 본사명 표시에 쓰인다 (임퍼소네이션 경로는 sealed 세션의 hqName 사용 — MeResponse 미경유).

ChangePasswordRequest (#007)

{ currentPassword: string; newPassword: string }

계정·인증 이메일 DTOs (#145)

링크(토큰 URL) 방식. token 은 메일 URL 의 ?token= 값(최대 256자, DB 엔 해시만 저장). 비밀번호는 8~100자(기존 변경 폼·ChangePasswordRequest 와 동일 정책).

// 계정 설정(setup)
SetupVerifyRequest    { token: string }
SetupVerifyResponse   { email: string; role: "OPERATOR"|"HQ_MANAGER"|"STORE_MANAGER"; expiresAt: string }
SetupCompleteRequest  { token: string; newPassword: string }   // → 204
 
// 비밀번호 재설정(reset)
PasswordResetRequestRequest { email: string }                  // → 204 (존재 은닉)
PasswordResetConfirmRequest { token: string; newPassword: string } // → 204
 
// 이메일 변경
EmailChangeRequestRequest   { newEmail: string }   // 인증된 본인(operator/hq/store me) → 204
EmailChangeConfirmRequest   { token: string }      // public, 새 주소 인증 → 204

발급 응답에서 계정 id 를 사용한다(setup 메일 재발송용): OperatorIssueResponse{id} · StoreManagerIssueResponse{storeId·managerAccountId}. 재발송은 path param resendAccountSetup(accountId) → 204.

ImpersonateExchangeRequest

{ exchangeToken: string }

ImpersonationExchangeResponse (#005 / #013 / #015)

/api/v1/auth/impersonate-exchange 응답. 60초·1회용 exchangeToken 을 본사 모드 access/refresh 한 쌍으로 교환한다. BFF 가 operatorEmail·hqName 을 sealed lm_impersonation_session 에 봉인해 /admin 셸 배너를 backend 호출 없이 렌더한다.

{
  accessToken: string;
  accessExpiresInSeconds: number;
  refreshToken: string;
  refreshExpiresAt: string;       // 임퍼소네이션은 60분
  accountId: string;              // 대상 HQ_MANAGER 계정
  role: "OPERATOR" | "HQ_MANAGER" | "STORE_MANAGER";
  impersonatedBy: string;         // 운영자(OPERATOR) accountId
  operatorEmail: string;          // 배너 표시용 운영자 이메일
  hqName: string;                 // 배너·상단바 표시용 본사명
}

Terms / Privacy DTOs

LegalDocumentResponse

{
  version: string;          // 예 "v1.0"
  content: string;          // markdown (FE 가 react-markdown + rehype-sanitize 로 렌더)
  publishedAt: string;      // ISO instant — 게시(저장) 시각
  effectiveAt: string;      // ISO instant — 발효 시각. 이 시각 이후 "현재 유효본" 후보 (#033)
  status: "SCHEDULED" | "ACTIVE" | "SUPERSEDED"; // 저장된 상태(게시 시점, stale 가능). 유효본 판정은 effectiveAt 기준 (#033)
}

status 는 게시 시점 스냅샷이라 예약본이 발효 시각을 지나도 SCHEDULED 로 남을 수 있다(스케줄러 없음). /active 응답은 status 를 항상 ACTIVE 로 보정한다. “현재 유효본”·온보딩 버전 검증은 effectiveAt <= now 중 최신으로 판정한다.

LegalDocumentHistoryResponse / LegalDocumentHistoryItem (#033)

// GET /api/v1/admin/{terms,privacy-policy}
{ items: LegalDocumentHistoryItem[] }   // effectiveAt DESC, id DESC
 
// LegalDocumentHistoryItem — 본문(content) 제외 (목록 경량화)
{
  id: string;            // UUID — by-id 상세 조회 키
  version: string;
  publishedAt: string;   // ISO instant
  effectiveAt: string;   // ISO instant
  status: "SCHEDULED" | "ACTIVE" | "SUPERSEDED";
}

본문은 GET /api/v1/admin/{terms,privacy-policy}/{id}LegalDocumentResponse(content 포함)로 반환.

LegalDocumentPublishRequest (POST /api/v1/admin/terms · privacy-policy)

{
  version: string;        // maxLength 32. 동일 version 중복은 409
  content: string;        // markdown. maxLength 200000
  effectiveAt?: string | null; // ISO instant. null·과거(±60s)=즉시 발효, 미래=예약. 과거(>60s)=400 (#033)
}

(PrivacyPolicy 도 동일 schema)

LegalAgreeRequest (POST /api/v1/legal/terms/agree · /api/v1/legal/privacy/agree · #040)

{
  version: string;   // maxLength 32. 동의할 문서 버전 — 현재 유효본과 일치해야 함.
                     // 불일치 시 400 INVALID_LEGAL_VERSION
}

재동의(SPEC #040) 요청 body. 응답은 204 No Content(멱등 — 이미 동의한 version 이면 no-op). agreedIp·agreedUserAgent·signerName·agreedAt 은 서버가 JWT principal·request 에서 자동 캡처(온보딩 동의 캡처 미러). terms·privacy 두 endpoint 가 같은 schema 를 공유한다.

Hq DTOs

HqOnboardingRequest (#010 / #013)

{
  hq: {
    name: string;
    businessNumber?: string;   // SPEC #013, ###-##-##### 패턴
    plan: "AI" | "TRUST";
    billingAnchorDay: number;  // 1~31
    llmKeywords?: string;
    llmMaxPerHour: number;     // 1~12
  };
  consent: { termsVersion: string; privacyPolicyVersion: string };
  manager: { email: string; tempPassword: string; name: string };
}

HqOnboardingResponse

{ hqId: string; managerAccountId: string }

HqAdminListResponse (#013 §2-3 · #045)

// generated `packages/api-client/src/generated/schemas/hqAdminListResponse.ts`
{
  items: HqAdminListItem[];
  page: number;   // 0-base 페이지 번호
  size: number;   // 페이지 크기 (1..100)
  total: number;  // 필터 적용 후 전체 건수
}

#045: {items}{items,page,size,total} envelope 로 전환(매장 #044 · 운영자 #043 미러). FE(/hq)는 total/size 로 총 페이지·이전/다음 disabled 를 계산한다. 정렬은 status 우선순위(UNPAID → SUSPENDED → ACTIVE → ONBOARDING) → name asc → id asc 고정(페이지 간 결정성).

HqAdminListItem (#013 · #045)

// generated `packages/api-client/src/generated/schemas/hqAdminListItem.ts`
{
  id: string;
  name: string;
  businessNumber?: string;
  type: "FRANCHISE" | "INDEPENDENT";   // HqType (Store 타입과 다름 — `DIRECT` 는 StoreType)
  plan?: "AI" | "TRUST";
  status: "ACTIVE" | "ONBOARDING" | "UNPAID" | "SUSPENDED";
  storeCount: number;                  // #045 — 현재 페이지 hqId 집합 한정 집계
  billingAnchorDay?: number;
  paymentDueDays?: number | null;  // #028 nullable — 미산정 시 null. generated `number | null`. FE `== null → "—"`
  createdAt: string;
}

AdminListHqsParams (#045 — GET /api/v1/admin/hq/admin-list query)

// generated `packages/api-client/src/generated/schemas/adminListHqsParams.ts`
{
  q?: string;        // 본사명·사업자번호 부분일치(대소문자 무시, max 100)
  status?: "ACTIVE" | "ONBOARDING" | "UNPAID" | "SUSPENDED";  // 미지정 = 가상 본사 포함 전체
  type?: "FRANCHISE" | "INDEPENDENT";
  plan?: "AI" | "TRUST";
  page?: number;     // 0-base (기본 0)
  size?: number;     // 1..100 clamp (기본 20)
}

GET /api/v1/admin/hq/admin-list(operationId adminListHqs)의 query. #045 에서 페이지네이션을 서버사이드로 옮기며 모든 필터(q·status·type·plan)를 서버 파라미터로 이전했다(client useMemo 필터는 “현재 페이지만” 거르게 되어 페이지네이션과 모순). FE /hq 는 client-query (useAdminListHqs(params))로 전환되어 applied/draft 필터 state·page state 를 client 에 보유한다.

HqListResponse (minimal — dropdown 용)

{ items: Array<{ id: string; name: string }> }

⚠️ operationId listHqs(/api/v1/admin/hq) — 매장 생성 dropdown 전용 minimal listing. 풀 필드 어드민 목록 adminListHqs(/admin/hq/admin-list)와 별개 endpoint(검색·페이지네이션 없음).

ImpersonationIssueResponse

// generated `packages/api-client/src/generated/schemas/impersonationIssueResponse.ts`
{ exchangeToken: string; expiresInSeconds: number }

SuspendRequest (#024 — suspend 요청 body)

// generated `packages/api-client/src/generated/schemas/suspendRequest.ts`
{ reason: string }  // @NotBlank, @Size(max = 255)

POST /api/v1/admin/hq/{id}/suspend 의 필수 body. 정지 사유(운영 감사용)를 기록한다. backend bean validation @NotBlank(공백-only 거부) + @Size(max = 255). OpenAPI 에는 maxLength 255 만 노출되고(NotBlank 는 미표현) FE 는 trim min(1)·max(255) zod 로 미러한다(frontend.md #8). reactivate 는 body 가 없다.

HqStatusResponse (#018 · #024 — suspend / reactivate 응답)

// generated `packages/api-client/src/generated/schemas/hqStatusResponse.ts`
{
  hqId: string;
  status: HqStatusResponseStatus; // ACTIVE | ONBOARDING | UNPAID | SUSPENDED
  suspensionReason?: string;      // #024 — SUSPENDED 일 때만 채워짐. 그 외엔 미포함(optional)
}

POST /api/v1/admin/hq/{id}/suspend · /reactivate 의 200 응답. 전이 후 status 를 반환. suspensionReason 은 generated 타입상 optional(?: string) — SUSPENDED 가 아니면 값이 없다(reactivate 시 clear → 미포함/undefined).

StoreSuspendRequest (#037 — 매장 suspend 요청 body)

// generated `packages/api-client/src/generated/schemas/storeSuspendRequest.ts`
{ reason: string }  // @NotBlank, @Size(max = 255)

POST /api/v1/admin/stores/{storeId}/suspend 의 필수 body. 정지 사유(운영 감사용)를 기록한다. HQ SuspendRequest 미러 — backend @NotBlank(공백-only 거부) + @Size(max = 255). OpenAPI 에는 maxLength 255 만 노출되고(NotBlank 는 미표현) FE 는 trim min(1)·max(255) zod 로 미러한다(frontend.md #8). reactivate 는 body 가 없다. lib/backend.ts alias = BackendStoreSuspendRequest.

StoreStatusResponse (#037 — 매장 suspend / reactivate 응답)

// generated `packages/api-client/src/generated/schemas/storeStatusResponse.ts`
{
  storeId: string;
  status: StoreStatusResponseStatus; // ACTIVE | SUSPENDED | INACTIVE
  suspensionReason?: string | null;  // SUSPENDED 직후만 채워짐. reactivate 시 null/미포함
}

POST /api/v1/admin/stores/{storeId}/suspend · /reactivate 의 200 응답. 전이 후 status 를 반환. HQ HqStatusResponse 대칭. lib/backend.ts alias = BackendStoreStatusResponse.

상세 응답 갭: StoreDetailResponse 에는 현재 suspensionReason 필드가 없다(HQ HqDetailResponse 와 비대칭 — 매장 상세 응답 DTO 미반영). FE 매장 상세 개요의 “정지 사유” 행은 SUSPENDED 일 때 노출하되 값이 없으면 로 둔다. backend 가 StoreDetailResponse.suspensionReason 을 추가하고 재-sync 하면 별도 FE 수정 없이 그대로 노출된다(후속).

StoreCloseRequest (#039 — 매장 폐점 요청 body)

// generated `packages/api-client/src/generated/schemas/storeCloseRequest.ts`
{ reason: string }  // @NotBlank, @Size(max = 255)

POST /api/v1/admin/stores/{storeId}/close 의 필수 body. 폐점 사유(운영 감사용)를 기록한다. StoreSuspendRequest 와 동일 제약 — backend @NotBlank(공백-only 거부) + @Size(max = 255). 폐점은 비가역이라 사유가 단일 보존처(감사 로그 detail). FE 는 trim min(1)·max(255) zod 로 미러하고 “되돌릴 수 없습니다” 경고 + 2-step 확인을 둔다(frontend.md #8). lib/backend.ts alias = BackendStoreCloseRequest.

StoreCloseResponse (#039 — 매장 폐점 응답)

// generated `packages/api-client/src/generated/schemas/storeCloseResponse.ts`
{
  storeId: string;
  closedAt: string;  // 폐점 시각 (ISO-8601). 이후 closedAt != null 이 폐점 판정
}

POST /api/v1/admin/stores/{storeId}/close 의 200 응답. status 는 반환하지 않는다(폐점은 status 와 독립된 terminal 축 — closedAt 만 채움). lib/backend.ts alias = BackendStoreCloseResponse.

HqDetailResponse (#020 — GET /api/v1/admin/hq/{id})

// generated `packages/api-client/src/generated/schemas/hqDetailResponse.ts`
{
  id: string;
  name: string;
  businessNumber?: string;
  type: "FRANCHISE" | "INDEPENDENT";          // HqType
  plan?: "AI" | "TRUST";
  status: "ACTIVE" | "ONBOARDING" | "UNPAID" | "SUSPENDED";  // HqStatus
  llmAutomentEnabled: boolean;
  llmKeywords?: string;
  llmMaxPerHour: number;
  billingAnchorDay?: number;
  storeCount: number;                         // countGroupedByHqId 재사용 (N+1 없음)
  suspensionReason?: string;                  // #024 — SUSPENDED 일 때만. 그 외엔 미포함(optional)
  createdAt: string;
  updatedAt: string;
  managers: HqManagerItem[];                  // role=HQ_MANAGER, findByHqIdAndRole
}

/hq/{id} 상세 페이지의 개요·계정 탭 데이터. 존재하지 않는 id → 404 HQ_NOT_FOUND (FE 가 notFound()). 매장 탭은 별도로 store admin-list ?hqId={id} 로 조회. suspensionReason 은 status 가 SUSPENDED 일 때만 채워지며(#024), 개요 탭에 “정지 사유” 행으로 표시된다(비-SUSPENDED 면 행 생략). SPEC #123 updateHq(PATCH /api/v1/admin/hq/{id})도 이 DTO 를 read-back 으로 재사용한다(별도 응답 DTO 없음 — 본사명 갱신 후 갱신된 상세를 200 으로 반환).

UpdateHqRequest (#123 — PATCH /api/v1/admin/hq/{id} body)

// generated `packages/api-client/src/generated/schemas/updateHqRequest.ts`
UpdateHqRequest {
  name: string;   // 변경할 본사 이름. 비-blank · @maxLength 255 (Hq.name 생성 제약 미러)
}

운영사(OPERATOR)가 본사명을 편집할 때 보내는 body(인터뷰 결정: 본사명 변경은 운영사만 — 본사 자기수정 불가). OpenAPI 에는 minLength 0·maxLength 255 로 노출되나 backend @NotBlank 가 런타임에서 공백-only 를 거부하므로, FE 는 trim 후 min(1) 로 미러(빈/blank 시 [저장] disabled). 응답은 HqDetailResponse read-back. apps/admin /hq/[id] 개요 본사명 인라인 편집 form 이 변경 시에만 (name.trim() !== 기존) 전송한다(no-op 차단). generated 훅 useUpdateHq.

HqManagerItem (#020)

// generated `packages/api-client/src/generated/schemas/hqManagerItem.ts`
{
  id: string;
  email: string;
  name?: string;
  status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN";  // AccountStatus
  passwordMustChange: boolean;                   // true → "변경 필요" 배지
  lastLoginAt?: string | null;                   // #028 nullable — 미로그인 시 null. FE `?? undefined → "—"`
  createdAt: string;
}

HqDetailResponse.managers 항목. 본사 소속 HQ_MANAGER 계정.

HqConsentsResponse (#127 — GET /api/v1/admin/hq/{id}/consents)

// generated `packages/api-client/src/generated/schemas/hqConsentsResponse.ts`
HqConsentsResponse {
  hqId: string;
  hqName: string;
  status: "ACTIVE" | "ONBOARDING" | "UNPAID" | "SUSPENDED";  // HqStatus (현재 협약 상태)
  terms: ConsentRecord[];                                    // 이용약관 동의 이력 (동의 시각 내림차순)
  privacy: ConsentRecord[];                                  // 개인정보처리방침 동의 이력 (동의 시각 내림차순)
  termsReagreeRequired: boolean;                             // 현재 유효 약관 버전 > 최신 동의 → true
  privacyReagreeRequired: boolean;                           // 현재 유효 개인정보 버전 > 최신 동의 → true
}

운영사(OPERATOR)가 /contracts 에서 본사별 동의 이력·협약 상태를 조회할 때 받는 응답(SPEC #127). 기존 HqConsent(약관)·HqPrivacyConsent(개인정보)·HqStatus 를 묶어 반환 — 신규 엔티티·마이그레이션 0. 재동의 필요는 기존 유효 버전 로직 재사용. 결제(단가·청구·만료)는 v1 제외(무료 MVP). 미존재 HQ → 404 HQ_NOT_FOUND. generated 훅 useGetHqConsents. → Contracts.

ConsentRecord (#127)

// generated `packages/api-client/src/generated/schemas/consentRecord.ts`
ConsentRecord {
  documentVersion: string;        // 동의한 문서 버전
  agreedAt: string;               // 동의 시각 (ISO-8601, FE 가 KST 표기)
  signerName?: string | null;     // 서명자 이름 (없으면 null → FE "—")
  agreedIp?: string | null;       // 동의 시 IP (없으면 null → FE "—")
}

HqConsentsResponse.terms·privacy 항목(AbstractConsent 필드). 동의 시각 내림차순 정렬.

Store DTOs

StoreOnboardingRequest (#011)

{
  store: {
    name: string;
    address?: string;
    managerName?: string;
    managerEmail?: string;
    managerPhone?: string;
    plan: "AI" | "TRUST";
    billingAnchorDay: number;
  };
  consent: { termsVersion: string; privacyPolicyVersion: string };
}

StoreOnboardingResponse

{ storeId: string }

StoreAdminListResponse (#019 · #044)

// generated `packages/api-client/src/generated/schemas/storeAdminListResponse.ts`
{
  items: StoreAdminListItem[];
  page: number;   // 0-base 페이지 번호
  size: number;   // 페이지 크기 (1..100)
  total: number;  // 필터 적용 후 전체 건수
}

#044: {items}{items,page,size,total} envelope 로 전환(운영자 #043 미러). FE 는 total/size 로 총 페이지·이전/다음 disabled 를 계산한다.

StoreAdminListItem (#019 · #044)

// generated `packages/api-client/src/generated/schemas/storeAdminListItem.ts`
{
  id: string;
  name: string;
  type: "INDEPENDENT" | "DIRECT" | "FRANCHISE";  // StoreType (HqType 과 다름 — `DIRECT` 는 StoreType 전용)
  status: "ACTIVE" | "INACTIVE" | "SUSPENDED";    // StoreStatus
  hqId: string;
  hqName: string;                // Hq(name) join — N+1 회피
  plan?: "AI" | "TRUST";
  address?: string;
  managerName?: string;
  billingAnchorDay?: number;
  lastOnlineAt?: string | null;  // #028 nullable — 미접속 시 null. FE `?? undefined → "—"` (온/오프라인 판정 v1 미포함)
  closedAt?: string | null;      // SPEC #044 D2 — 폐점 시각(ISO-8601). 폐점 안 했으면 null. status 미지정 = 폐점 포함 전체이므로, FE 가 행에 폐점 배지를 노출
  createdAt: string;
  hasManagerAccount: boolean;    // SPEC #021 — 해당 매장에 role=STORE_MANAGER OperatorAccount 존재 여부(exists 집계, N+1 없음). FE 가 [계정 발급]/"발급됨" 분기
}

AdminListStoresParams (#044 — GET /api/v1/admin/stores/admin-list query)

// generated `packages/api-client/src/generated/schemas/adminListStoresParams.ts`
{
  q?: string;        // 매장명·본사명·주소 부분일치(대소문자 무시, max 100)
  status?: "ACTIVE" | "SUSPENDED" | "INACTIVE";   // 미지정 = 폐점 포함 전체
  type?: "DIRECT" | "FRANCHISE" | "INDEPENDENT";
  plan?: "AI" | "TRUST";
  hqId?: string;     // 특정 본사 매장만 스코프 (#020 — HQ 상세 매장 탭). 미지정 시 전체
  page?: number;     // 0-base (기본 0)
  size?: number;     // 1..100 clamp (기본 20)
}

GET /api/v1/admin/stores/admin-list(operationId adminListStores)의 200 응답·query. 정렬: status priority(SUSPENDED → INACTIVE → ACTIVE) → name asc → id asc 고정(페이지 간 결정성). #044 에서 페이지네이션을 서버사이드로 옮기며 모든 필터(q·status·type·plan·hqId)를 서버 파라미터로 이전했다 — 부분 서버화 시 client 필터가 “현재 페이지만” 거르게 되어 깨지기 때문이다. HQ 상세 매장 탭은 { hqId, size: 100 } 로 본사 매장 전체를 확보한다(페이지네이션 UI 없음).

StoreDetailResponse (#036 — GET /api/v1/admin/stores/{id})

// generated `packages/api-client/src/generated/schemas/storeDetailResponse.ts`
{
  id: string;
  name: string;
  type: "INDEPENDENT" | "DIRECT" | "FRANCHISE";   // StoreType
  status: "ACTIVE" | "INACTIVE" | "SUSPENDED";     // StoreStatus
  hqId: string;
  hqName: string;                  // Hq(name) join — N+1 회피
  plan?: "AI" | "TRUST" | null;
  address?: string | null;
  managerName?: string | null;     // 매장 담당자(점장 연락처용)
  managerEmail?: string | null;
  managerPhone?: string | null;
  billingAnchorDay?: number | null;
  lastOnlineAt?: string | null;    // ISO-8601, 미접속 시 null → FE "—"
  lastHeartbeatAt?: string | null; // ISO-8601, 없으면 null → FE "—"
  closedAt?: string | null;        // 폐점 시각, 있으면 개요 "폐점일" danger highlight 행
  createdAt: string;
  updatedAt: string;
  managers: StoreManagerSummaryItem[];  // role=STORE_MANAGER, SUSPENDED·WITHDRAWN 포함
}

/stores/{id} 상세 페이지의 개요·점장 탭 데이터. HQ 상세 HqDetailResponse 대칭. 존재하지 않는 id → 404 STORE_NOT_FOUND(FE 가 notFound()). 결제·활동 탭은 placeholder(정산·감사로그 미구축). 점장 탭은 발급(#021)·관리(#029) 다이얼로그를 상세 컨텍스트에서 진입점으로 재사용하며, 액션 성공 시 router.refresh() 로 상세를 재검증한다. null 필드는 개요에서 ’—’ 표기.

StoreManagerSummaryItem (#036)

// generated `packages/api-client/src/generated/schemas/storeManagerSummaryItem.ts`
{
  id: string;
  email: string;
  name?: string | null;
  status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN";  // AccountStatus
  passwordMustChange: boolean;                   // true → "변경 필요" 배지
  lastLoginAt?: string | null;                   // 미로그인 시 null → FE "—"
  createdAt: string;
}

StoreDetailResponse.managers 항목. 매장 소속 STORE_MANAGER 계정(SUSPENDED·WITHDRAWN 포함 — #029 list 일관). HQ 상세 HqManagerItem 대칭.

StoreManagerIssueRequest (#021)

// generated `packages/api-client/src/generated/schemas/storeManagerIssueRequest.ts`
{
  email: string;         // OpenAPI maxLength 255 (backend 런타임 @Email)
  tempPassword: string;  // OpenAPI minLength 8, maxLength 100 — 최초 로그인 시 변경(passwordMustChange)
  name: string;          // OpenAPI maxLength 255 (backend 런타임 @NotBlank)
}

POST /api/v1/admin/stores/{storeId}/managers 의 요청 본문. HQ_MANAGER ManagerPart 미러. 생성 OpenAPI 스키마에는 길이 제약만 표현되고 email format·NotBlank 는 노출되지 않는다(backend 가 런타임 bean validation 으로 enforce). FE zod 검증(store-manager-issue-dialog)은 그 런타임 규칙을 미러해 사전 reject.

StoreManagerIssueResponse (#021)

// generated `packages/api-client/src/generated/schemas/storeManagerIssueResponse.ts`
{
  storeId: string;
  managerAccountId: string;  // 생성된 OperatorAccount(role=STORE_MANAGER, storeId tenancy) id
}

발급 성공 200 응답(생성 client status 200 고정). FE 는 본문을 사용하지 않고, /stores 목록에서는 콜백(onIssued → 현재 필터 useAdminListStores query invalidate, #044 — client-query 전환)·/stores/{id} 상세에서는 router.refresh()hasManagerAccount 를 갱신한다. 에러: 매장 X → 404 STORE_NOT_FOUND · email 중복 → 409 DUPLICATE_EMAIL · 권한 → 403.

StoreManagerListResponse · StoreManagerListItem (#029)

// generated `schemas/storeManagerListResponse.ts` · `schemas/storeManagerListItem.ts`
StoreManagerListResponse { items: StoreManagerListItem[] }   // createdAt asc, id asc
 
StoreManagerListItem {
  id: string;
  email: string;
  name?: string | null;                                  // 없으면 null
  status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN";          // StoreManagerListItemStatus
  passwordMustChange: boolean;                            // 임시 비번 상태(첫 로그인 변경 강제)
  lastLoginAt?: string | null;                            // ISO-8601, 없으면 null
  createdAt: string;                                      // ISO-8601
}

GET /api/v1/admin/stores/{storeId}/managers 의 200 응답. FE 관리 다이얼로그(store-manager-manage-dialog)가 계정 카드로 렌더 — lastLoginAt/createdAt 은 KST(Intl Asia/Seoul) 표기.

ResetPasswordRequest · StoreManagerSuspendRequest · StoreManagerAccountResponse (#029)

// generated `schemas/resetPasswordRequest.ts` · `storeManagerSuspendRequest.ts` · `storeManagerAccountResponse.ts`
ResetPasswordRequest         { tempPassword: string; }    // OpenAPI minLength 8, maxLength 100
StoreManagerSuspendRequest   { reason: string; }          // OpenAPI maxLength 255 (backend 런타임 @NotBlank)
 
StoreManagerAccountResponse {
  id: string;
  status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN";          // 전이 후 상태
  passwordMustChange: boolean;                            // 비번 재설정 시 true
}
  • reset-password 요청 본문 = ResetPasswordRequest, 응답 = StoreManagerAccountResponse. ACTIVE 계정만 (그 외 → 409 ACCOUNT_INVALID_STATUS_TRANSITION). FE zod 가 길이 제약(min 8/max 100)을 미러해 사전 reject.
  • suspend 요청 본문 = StoreManagerSuspendRequest. OpenAPI 에는 길이만 노출되고 NotBlank 는 런타임 — FE zod 가 trim 후 min 1 로 미러.
  • reactivate(body 없음)·revoke(DELETE, body 없음)는 상태 전이만. 잘못된 전이 → 409 ACCOUNT_INVALID_STATUS_TRANSITION · 미존재 → 404 STORE_MANAGER_NOT_FOUND/STORE_NOT_FOUND.

HQ Mode / Store Mode DTOs (#049 — 본사·점장 본인 프로필)

본사(HQ_MANAGER)·매장(STORE_MANAGER) 본인이 자기 소속을 조회하는 me 응답(#049). 운영사(OPERATOR)의 어드민 상세(HqDetailResponse·StoreDetailResponse)와 달리 본인 한정(타 테넌트 조회 불가)이며, service 가 PrincipalScopeGuard 로 검증한 DB 기준 hqId/storeId 로만 조회한다(claim 비신뢰 — Auth Model).

HqMeResponse (#049 — GET /api/v1/hq/me)

// generated `packages/api-client/src/generated/schemas/hqMeResponse.ts`
{
  id: string;                          // 본사 id (UUID)
  name: string;                        // 본사명
  type: "FRANCHISE" | "INDEPENDENT";   // HqMeResponseType — HqType (Store 타입과 다름)
  plan?: "AI" | "TRUST" | null;        // @nullable — 요금제 없으면 null
  status: "ACTIVE" | "ONBOARDING" | "UNPAID" | "SUSPENDED";  // HqMeResponseStatus
  businessNumber?: string | null;      // @nullable — 사업자등록번호(NNN-NN-NNNNN), 없으면 null
  billingAnchorDay?: number | null;    // @nullable — 청구 기준일(1~31), 없으면 null
  storeCount: number;                  // 소속 매장 수
  commercialCycleSongs: number;        // SPEC #095 — 본사 CM송 사이클 빈도(N곡마다 1회). V32 default 5 · 1~100
  duckEnabled: boolean;                // 더킹 — 멘트 중 배경음악 감쇠 사용 여부 default. BE 신규 default true
  duckVolumePercent: number;           // 더킹 목표 볼륨(정상 대비 %, 0~100). BE 신규 default 20
  duckFadeMs: number;                  // 더킹 fade in/out 시간(ms, 0~5000). BE 신규 default 400
  createdAt: string;                   // 본사 생성 시각 (ISO-8601)
}

operationId getHqMe. 현재 인증된 HQ_MANAGER 의 소속 본사 요약. claim↔DB 불일치·비활성(SUSPENDED·WITHDRAWN)·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 본사 데이터 미존재 404. generated 훅 useGetHqMe. plan enum 은 HqMeResponsePlan(AI·TRUST, nullable), typeHqMeResponseType, statusHqMeResponseStatus.

UpdateHqMeRequest (#085 — PATCH /api/v1/hq/me)

// generated `packages/api-client/src/generated/schemas/updateHqMeRequest.ts`
{
  /** 본사 매니저 본인 이름. 생략 또는 null = 미변경. non-null 이면 1~50자. */
  name?: string | null;  // @minLength 1 @maxLength 50 @nullable
  /** SPEC #095 — 본사 CM송 사이클 빈도(N곡마다 1회 삽입). 생략 또는 null = 미변경. non-null 이면 1~100. */
  commercialCycleSongs?: number | null;  // @minimum 1 @maximum 100 @nullable
}

operationId updateHqMe. 현재 인증된 HQ_MANAGER 의 본인 매니저 계정 이름(name) + 본사 CM 사이클 빈도(commercialCycleSongs, SPEC #095) 를 편집한다(부분 업데이트). null/생략 = 미변경. 응답은 HqMeResponse(GET 과 동일 DTO 재사용 — 본사 요약을 그대로 반환). email 변경 금지·비밀번호 변경 금지(별도 endpoint /onboarding/change-password SPEC #003). 본사명·다중 매니저 관리는 운영자 영역(F1·F4 후속). claim↔DB 불일치·비활성·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 빈/blank name 400 · commercialCycleSongs 범위 밖 400. generated 훅 useUpdateHqMe.

HqMeResponse.name본사명이고 UpdateHqMeRequest.name본인 매니저 본인 이름 — 의미가 다르다. apps/space /admin/settings 본인 매니저 이름 편집 form 은 me 응답을 입력 초기값에 쓰지 않고 빈 문자열에서 시작한다(혼동 회피). 본인 매니저 name read 는 F 후속 슬라이스(me 확장).

UpdateHqDuckingRequest (더킹 default — PATCH /api/v1/hq/me/ducking)

// generated `packages/api-client/src/generated/schemas/updateHqDuckingRequest.ts`
UpdateHqDuckingRequest {
  duckEnabled: boolean;       // 필수
  duckVolumePercent: number;  // 필수 · @minimum 0 @maximum 100
  duckFadeMs: number;         // 필수 · @minimum 0 @maximum 5000
}

operationId updateHqDucking. HQ_MANAGER-only. 현재 인증된 본사의 더킹 default 를 편집한다 — 멘트 (안내방송·CM) 재생 동안 배경음악을 정지하지 않고 볼륨만 감쇠해 동시 재생하는 동작의 산하 매장 기본값. 응답 200 HqMeResponse(GET 과 동일 DTO 재사용 — duck* 가 노출돼 FE 가 새 값 read-back). 매장별 override 는 매장 상세에서(updateStoreDucking). ⚠️ 부분 PATCH 가 아니라 3필드 전체 set(전체 replace) — 본사 default 는 항상 세 값이 존재하므로 한 필드만 바꿔도 세 값을 모두 전송한다. CM 사이클 빈도(#095)와 같은 본사 default + 매장 override 위계. 검증: duckVolumePercent 0100, duckFadeMs 05000(BE invariant Hq.kt). 범위 밖 400 · claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useUpdateHqDucking. apps/space /admin/settings 더킹 default 섹션이 사용 토글 + 볼륨%(0100) + fade ms(05000) + [저장] 으로 소비(off 면 멘트 중 음악 정지 = 종전 동작).

StoreMeResponse (#049 — GET /api/v1/store/me)

// generated `packages/api-client/src/generated/schemas/storeMeResponse.ts`
{
  id: string;                                    // 매장 id (UUID)
  name: string;                                  // 매장명
  type: "DIRECT" | "FRANCHISE" | "INDEPENDENT";  // StoreMeResponseType — StoreType (HqType 과 다름)
  status: "ACTIVE" | "SUSPENDED" | "INACTIVE";   // StoreMeResponseStatus
  hqId: string;                                  // 소속 본사 id (UUID)
  hqName: string;                                // 소속 본사명 (Hq(name) join)
  plan?: "AI" | "TRUST" | null;                  // @nullable — 요금제 없으면 null
  address?: string | null;                       // @nullable — 매장 주소, 없으면 null
  billingAnchorDay?: number | null;              // @nullable — 청구 기준일(1~31), 없으면 null
  hqCommercialCycleSongs: number;                // SPEC #095 — 소속 본사 CM송 사이클 빈도 default(N곡마다 1회, transparency 노출)
  storeCommercialCycleSongs: number | null;      // SPEC #103 — 매장 override (null=없음, 1..100=적용). 본사가 매장 상세에서 편집
  commercialCycleSongs: number;                  // SPEC #103 — **effective 값**(매장 override ?? HQ default). 점장 player 가 직접 소비
  duckEnabled: boolean;                          // 더킹 — **effective 값**(매장 override ?? HQ default). 점장 player 가 직접 소비
  duckVolumePercent: number;                     // 더킹 목표 볼륨(%) effective(0~100). 매장 override ?? HQ default
  duckFadeMs: number;                            // 더킹 fade 시간(ms) effective(0~5000). 매장 override ?? HQ default
  createdAt: string;                             // 매장 생성 시각 (ISO-8601)
}

operationId getStoreMe. 현재 인증된 STORE_MANAGER 의 소속 매장 요약. HQ HqMeResponse 대칭이되 소속 본사(id·name)를 포함한다. claim↔DB 불일치·비활성·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 매장 데이터 미존재 404. generated 훅 useGetStoreMe. plan enum 은 StoreMeResponsePlan(nullable), typeStoreMeResponseType, statusStoreMeResponseStatus.

HQ HqMeResponse 와 비대칭 — 매장 me 에는 businessNumber 가 없고(매장 단위 미보유) 대신 소속 본사(hqId·hqName)를 포함한다.

UpdateStoreMeRequest (#087 — PATCH /api/v1/store/me)

// generated `packages/api-client/src/generated/schemas/updateStoreMeRequest.ts`
{
  /** 점장 매니저 본인 이름. 생략 또는 null = 미변경. non-null 이면 1~50자. */
  name?: string | null;  // @minLength 1 @maxLength 50 @nullable
}

operationId updateStoreMe. 현재 인증된 STORE_MANAGER 의 본인 매니저 계정 이름(name) 만 편집한다 (부분 업데이트). null/생략 = 미변경. 응답은 StoreMeResponse(GET 과 동일 DTO 재사용 — 매장 요약을 그대로 반환). email 변경 금지·비밀번호 변경 금지(별도 endpoint /onboarding/change-password SPEC #003). 매장 정보 편집은 운영사/본사 영역(F1 후속). claim↔DB 불일치·비활성·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401 · 빈/blank name 400. generated 훅 useUpdateStoreMe. 본사 #085 UpdateHqMeRequest 와 동형 시그니처.

StoreMeResponse.name매장명이고 UpdateStoreMeRequest.name본인 매니저 본인 이름 — 의미가 다르다(본사 #085 와 동일 패턴). apps/space /store/profile 본인 매니저 이름 편집 form 은 me 응답을 입력 초기값에 쓰지 않고 빈 문자열에서 시작한다(혼동 회피). 본인 매니저 name read 는 F 후속 슬라이스(me 확장).

StoreOwnPlaylistListResponse / StoreOwnPlaylistListItemResponse / ListStorePlaylistsParams (#129 — GET /api/v1/store/playlists)

// generated `packages/api-client/src/generated/schemas/storeOwnPlaylistListResponse.ts`
StoreOwnPlaylistListResponse {
  items: StoreOwnPlaylistListItemResponse[];  // 선택 가능 PL 목록(자기 본사 활성 PL)
  page: number;                               // 0-base
  size: number;
  total: number;
}
 
// storeOwnPlaylistListItemResponse.ts
StoreOwnPlaylistListItemResponse {
  id: string;
  name: string;
  isDefault: boolean;       // 본사 기본 PL 여부(활성 미선택 시 점장 큐 fallback 대상)
  libraryCount: number;     // 담긴 활성 라이브러리 수
  status: "EMPTY" | "UNUSED" | "ACTIVE" | "FALLBACK";  // 파생 상태(응답 계산값). 배타 우선순위 EMPTY>FALLBACK>ACTIVE>UNUSED
  active: boolean;          // 현재 이 매장의 활성 PL 인지(현재 선택 표시용)
  updatedAt: string;        // ISO-8601
}
 
// listStorePlaylistsParams.ts — query
ListStorePlaylistsParams { q?: string; page?: number; size?: number; }

operationId listStorePlaylists. 점장(STORE_MANAGER)이 활성 PL 로 선택할 수 있는 본인 매장 소속 본사 PL 목록(SPEC #129). 경로/쿼리에 hqId·storeId 없음 — 토큰 claim DB 재검증으로 본인 본사 PL 로만 스코프. 정렬 updated_at DESC. status 는 저장 아닌 응답 계산값(라이브러리 0=EMPTY·본사 기본=FALLBACK·적용 매장 ≥1=ACTIVE·그 외=UNUSED). generated 훅 useListStorePlaylists. → Store Active Playlist.

SetStoreOwnActivePlaylistRequest (#129 — PATCH /api/v1/store/me/active-playlist body)

// generated `packages/api-client/src/generated/schemas/setStoreOwnActivePlaylistRequest.ts`
SetStoreOwnActivePlaylistRequest {
  playlistId?: string | null;   // 적용할 PL id(자기 본사 활성 PL). null = 활성 해제(본사 기본 PL fallback)
}

점장이 본인 매장 활성 PL 을 선택(non-null)하거나 해제(null=본사 기본 fallback)할 때 보내는 body(SPEC #129). 타 본사·미존재·삭제 PL → 404 PLAYLIST_NOT_FOUND(존재 은닉). plan 위반 PL 선택해도 차단 안 함 (큐 빌드 #122 가 필터). 응답은 아래 StoreActivePlaylistResponse(적용 PL 요약, 운영사 #055 와 공유 DTO). 실제 변경 시 매장 감사 1건 STORE_ACTIVE_PLAYLIST_CHANGED(#114). generated 훅 useSetStoreOwnActivePlaylist.

StoreActivePlaylistResponse (#055 도입 · #129 점장 self 경로 공유)

// generated `packages/api-client/src/generated/schemas/storeActivePlaylistResponse.ts`
StoreActivePlaylistResponse {
  active: boolean;                // 활성 PL 적용 여부 (미적용 시 false)
  playlistId?: string | null;     // 적용된 PL id (미적용 시 null)
  name?: string | null;           // 적용된 PL 이름 (미적용 시 null)
  libraryCount?: number | null;   // 담긴 활성 라이브러리 수 (미적용 시 null)
  appliedAt?: string | null;      // 적용 시각 근사 (store 갱신 시각, 미적용 시 null, ISO-8601)
}

매장 활성 PL 요약. 운영사 getStoreActivePlaylist/setStoreActivePlaylist(#055)와 점장 setStoreOwnActivePlaylist(#129)가 공유하는 응답 DTO. 미적용(본사 기본 fallback) 이면 active=false· 나머지 null.

HQ Mode 대시보드·산하 매장 DTOs (#051 — apps/space /admin·/admin/stores)

본사(HQ_MANAGER) 본인이 자기 산하 매장 현황·목록을 보는 read 응답(#051). getHqMe(#049)와 동일하게 PrincipalScopeGuard 로 확정한 본인 hqId 스코프로만 조회한다(요청에 hqId 파라미터 없음 — 타 본사 미노출).

HqDashboardResponse (#051 — GET /api/v1/hq/dashboard)

// generated `packages/api-client/src/generated/schemas/hqDashboardResponse.ts`
{
  totalStores: number;      // 산하 매장 총수 (ACTIVE+INACTIVE+SUSPENDED, 폐점 포함)
  activeStores: number;     // ACTIVE 매장 수
  inactiveStores: number;   // INACTIVE 매장 수
  suspendedStores: number;  // SUSPENDED 매장 수
  closedStores: number;     // 폐점(closedAt 설정) 매장 수
}

operationId getHqDashboard. 산하 매장 상태별 집계. 송출·정산 지표는 방송·billing 도메인 미도착이라 미포함(후속 §F1). claim↔DB 불일치·비활성·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqDashboard. apps/space /admin 대시보드가 server component 에서 refresh-aware 로 소비 — 산하 매장(총수+상태별 배지)·폐점 카드. 정산 카드는 “준비 중” placeholder. “오늘 송출 방송” placeholder 는 SPEC #119 F3 에서 실데이터 “오늘 송출 미도달 매장” 카드(아래 HqUndeliveredTodayResponse, client island)로 교체했다.

HqUndeliveredTodayResponse (#119 F3 — GET /api/v1/hq/dashboard/undelivered-today)

// generated `packages/api-client/src/generated/schemas/hqUndeliveredTodayResponse.ts`
{
  undeliveredStoreCount: number;  // 오늘(KST) 송출 중 미도달(PENDING) distinct 매장 수
}

operationId getHqUndeliveredToday(D4). dispatch 는 미ack 시 영구 PENDING 이라 전체 카운트는 노이즈 누적 → 오늘(KST) 윈도우로 한정 집계(영구 PENDING 누적 회피). hqId 토큰 주체 도출(타 본사 미집계). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqUndeliveredToday. apps/space /admin 대시보드 “오늘 송출 미도달 매장” 카드(UndeliveredTodayCard client island)가 60초 폴링(refetchInterval:60s·refetchIntervalInBackground:false)으로 소비 — count>0 면 danger 강조(클릭 시 /admin/audit), 0 이면 “모두 도달” 비강조.

HqSupportUnreadSignalResponse (#119 F4 — GET /api/v1/hq/support/unread-signal)

// generated `packages/api-client/src/generated/schemas/hqSupportUnreadSignalResponse.ts`
{
  latestOperatorReplyAt?: string | null;  // 본인 본사 ticket 의 운영자 REPLY max createdAt(없으면 null)
  openOrInProgressCount: number;           // 미해결(OPEN+IN_PROGRESS) ticket 수(보조)
}

operationId getHqSupportUnreadSignal(D2 dot). latestOperatorReplyAt 은 ticket.updatedAt 이 아닌 댓글 createdAt 별도 조회(댓글이 updatedAt 미갱신 — 주의 2). hqId 격리. FE 는 이 값을 localStorage lastSeen(lm.support.lastSeen.hq.<hqId>)과 비교해 dot 판정(D1 — 신규 테이블 0). generated 훅 useGetHqSupportUnreadSignal. apps/space HQSidebar /admin/support 항목 dot(60초 폴링).

HqStoreListResponse / HqStoreListItem (#051 — GET /api/v1/hq/stores)

// generated `packages/api-client/src/generated/schemas/hqStoreListResponse.ts`·`hqStoreListItem.ts`
HqStoreListResponse {
  items: HqStoreListItem[];
  page: number;   // 0-base
  size: number;   // 1..100
  total: number;  // 필터 적용 후 전체 건수
}
 
HqStoreListItem {
  id: string;
  name: string;
  type: "DIRECT" | "FRANCHISE" | "INDEPENDENT";  // HqStoreListItemType
  status: "ACTIVE" | "SUSPENDED" | "INACTIVE";   // HqStoreListItemStatus
  address?: string | null;       // @nullable
  managerName?: string | null;   // @nullable — 매장 담당자 이름
  lastOnlineAt?: string | null;  // @nullable — ISO-8601
  closedAt?: string | null;      // @nullable — 폐점 시각 (FE 폐점 배지)
  createdAt: string;             // ISO-8601
  hasManagerAccount: boolean;    // STORE_MANAGER(점장) 계정 존재 여부
}

operationId listHqStores. query q?(매장명·주소 부분일치, 대소문자 무시, ≤100)·status?(ListHqStoresStatustype?(ListHqStoresTypepage·size. 정렬 status 우선순위(SUSPENDED·INACTIVE 우선)→name asc→id asc. claim↔DB 불일치·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqStores. apps/space /admin/stores 가 운영사 /stores(#044) 패턴(공용 ListToolbar/ListPagination)으로 소비 — read-only(점장 발급/관리·본사명·plan 필터 없음).

운영사 어드민 StoreAdminListItem(#044)과 비대칭 — HQ 산하 목록은 단일 본사라 hqId·hqName·plan·billingAnchorDay 가 없고 본사 스코프 매장 행만 노출한다.

HqStoreDetailResponse / HqStoreManagerInfo / HqStoreActivePlaylistInfo (#084·#103·#116 — GET /api/v1/hq/stores/{id})

// generated `packages/api-client/src/generated/schemas/hqStoreDetailResponse.ts`·
// `hqStoreManagerInfo.ts`·`hqStoreActivePlaylistInfo.ts`
HqStoreDetailResponse {
  id: string;
  name: string;
  address?: string | null;       // @nullable
  managerName?: string | null;   // @nullable — store.manager_name 연락 컬럼 (#116). 점장 계정과 별개
  managerEmail?: string | null;  // @nullable — store.manager_email 연락 컬럼 (#116)
  phone?: string | null;         // @nullable — store.manager_phone 컬럼
  type: "DIRECT" | "FRANCHISE" | "INDEPENDENT";   // HqStoreDetailResponseType
  status: "ACTIVE" | "SUSPENDED" | "INACTIVE";    // HqStoreDetailResponseStatus
  closedAt?: string | null;      // @nullable — 폐점 시각 (FE 폐점 배지)
  storeManager?: HqStoreManagerInfo;     // null = 점장 계정 미발급
  activePlaylist?: HqStoreActivePlaylistInfo;  // null = 활성 PL 없음(본사 기본 PL fallback 가능)
  commercialCycleSongs?: number | null;  // SPEC #103 — 매장 CM 사이클 빈도 override (null=HQ default 사용, 1..100=매장 override)
  region?: Region | null;                // SPEC #144 — 매장 지역(시/도) enum 17종 (null=미지정). REGION 모드 송출 그룹핑 축. HqStoreDetailResponseRegion
  duckEnabled?: boolean | null;          // 더킹 사용 여부 override (null=없음, HQ default 사용)
  duckVolumePercent?: number | null;     // 더킹 목표 볼륨(%) override (null=없음, 0..100=매장 override)
  duckFadeMs?: number | null;            // 더킹 fade 시간(ms) override (null=없음, 0..5000=매장 override)
  hqDuckEnabled: boolean;                // 본사 더킹 사용 여부 default (참조용 — override UI 상속 표기)
  hqDuckVolumePercent: number;           // 본사 더킹 목표 볼륨(%) default (참조용, 0..100)
  hqDuckFadeMs: number;                  // 본사 더킹 fade 시간(ms) default (참조용, 0..5000)
  createdAt: string;             // ISO-8601
  updatedAt: string;             // ISO-8601
}
 
// 점장(STORE_MANAGER) 계정 정보 (미발급 시 null). 가장 먼저 생성된 1건.
HqStoreManagerInfo = {
  email: string;
  name?: string | null;          // @nullable
  status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN";   // HqStoreManagerInfoStatus
} | null
 
// 매장 활성 플레이리스트 (없음/soft-delete 시 null — 본사 기본 PL fallback 상태는 별도 표현).
HqStoreActivePlaylistInfo = {
  id: string;
  name: string;
} | null

operationId getHqStore. 매장.hqId ≠ 주체 hqId 또는 미존재 → 404 STORE_NOT_FOUND(타 본사 매장 존재 은닉). claim↔DB 재검증 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqStore·getGetHqStoreQueryKey. apps/space /admin/stores/[id] 가 5 섹션(개요/점장 정보/활성 PL/운영 상태/메타) + CM 사이클 빈도 섹션(#103, 매장 override 편집) 로 소비. managerName·managerEmail(#116)은 개요 카드에 담당자 행으로 노출되고, 매장 정보 편집 폼(#105)이 5필드 현재값을 prefill 하는 read 소스다(점장 계정 storeManager 와 별개의 store 연락 컬럼).

updateStoreCommercialCycle / UpdateStoreCommercialCycleRequest (#103 — PATCH /api/v1/hq/stores/{id}/commercial-cycle)

// generated `packages/api-client/src/generated/schemas/updateStoreCommercialCycleRequest.ts`
UpdateStoreCommercialCycleRequest {
  commercialCycleSongs?: number | null;  // null = override 제거(HQ default 로 복귀), 1..100 = 매장 override 적용
}

operationId updateStoreCommercialCycle. HQ_MANAGER-only. 본사 매니저가 산하 매장의 CM 사이클 빈도를 본사 default 와 다르게 설정한다(#094 F2 마감). 응답 204 — FE 가 매장 상세 invalidate 후 refetch. 본사 격리: WHERE store.hq_id = :hqId AND store.id = :id 강제 — 타 본사 매장은 404 STORE_NOT_FOUND 은닉(#084 패턴). validation: @field:Min(1) @field:Max(100) + entity init 가드. generated 훅 useUpdateStoreCommercialCycle. apps/space /admin/stores/[id] CM 사이클 섹션이 라디오(default/override) + number input + [저장] 으로 소비.

updateStoreDucking / UpdateStoreDuckingRequest (더킹 override — PATCH /api/v1/hq/stores/{id}/ducking)

// generated `packages/api-client/src/generated/schemas/updateStoreDuckingRequest.ts`
UpdateStoreDuckingRequest {
  duckEnabled?: boolean | null;       // @nullable — null=override 제거(HQ default 복귀), non-null=override 적용
  duckVolumePercent?: number | null;  // @nullable @minimum 0 @maximum 100 — null=override 제거
  duckFadeMs?: number | null;         // @nullable @minimum 0 @maximum 5000 — null=override 제거
}

operationId updateStoreDucking. HQ_MANAGER-only. 본사 매니저가 산하 매장의 더킹을 본사 default 와 다르게 설정한다(CM 사이클 override 미러). 응답 204 — FE 가 매장 상세 invalidate 후 refetch(effective 가 점장 player me 로 read-back). ⚠️ per-field null=override 제거 의미의 전체 replace — 부분 PATCH 가 아니라 세 필드를 항상 함께 전송한다. UI 가 “본사 기본값 사용(상속)” 모드면 세 필드 모두 null(override 전체 제거 = HQ default 상속), “이 매장만 다르게(override)” 모드면 세 필드 모두 입력값. 현재 override / HQ default 참조값은 HqStoreDetailResponseduck* / hqDuck* 에서. 본사 격리: WHERE store.hq_id = :hqId AND store.id = :id 강제 — 타 본사 매장은 404 STORE_NOT_FOUND 은닉. 검증: duckVolumePercent 0100, duckFadeMs 05000. generated 훅 useUpdateStoreDucking. apps/space /admin/stores/[id] 더킹 override 섹션이 라디오(상속/override) + 사용 토글 + 볼륨% + fade ms + [저장] 으로 소비.

updateStoreRegion / UpdateStoreRegionRequest (#144 — PATCH /api/v1/hq/stores/{id}/region)

// generated `packages/api-client/src/generated/schemas/updateStoreRegionRequest.ts`
UpdateStoreRegionRequest {
  region?: Region | null;  // @nullable — Region 시/도 enum 17종. 키+value=set / 키+null=clear(미지정). UpdateStoreRegionRequestRegion
}

operationId updateStoreRegion. HQ_MANAGER-only. 본사 매니저가 산하 매장에 지역(시/도) 을 태그한다 (REGION 모드 송출의 그룹핑 축, #144). null=미지정(clear) — 미지정 매장은 지역 송출에 포함되지 않는다. 응답 204 — FE 가 매장 상세 invalidate 후 refetch(HqStoreDetailResponse.region 으로 read-back). 본사 격리: WHERE store.hq_id = :hqId AND store.id = :id 강제 — 타 본사 매장은 404 STORE_NOT_FOUND 은닉. 같은 트랜잭션에 audit HqAuditAction.HQ_STORE_REGION_UPDATED + HqAuditTargetType.STORE(before/after region 스냅샷) 1건. generated 훅 useUpdateStoreRegion. apps/space /admin/stores/[id] 매장 지역 섹션이 시/도 select(+ “미지정” 옵션) + [저장] 으로 소비 (hq-store-detail-client.tsx RegionSection). FE 한글 라벨 맵은 apps/space/src/lib/region.ts 단일 소스.

UpdateHqStoreRequest (#105 — PATCH /api/v1/hq/stores/{id})

// generated `packages/api-client/src/generated/schemas/updateHqStoreRequest.ts`
// 모든 필드는 BE 에서 `JsonNullable<String>` 으로 받는다 — PATCH 의미 D2:
//   · 키 부재         = 미변경
//   · 키 + null       = clear (단 name 은 비-nullable 컬럼이라 null 시 400)
//   · 키 + value      = set (검증 통과 시)
// orval 이 JsonNullable 의 partial 의미를 OpenAPI 로 표현하지 못해 generated 타입은 5 필드 모두
// required 로 떨어진다. FE 는 변경된 필드만 담은 `Partial<>` payload 를 generated client 에 cast
// 해 보낸다(runtime 으로만 D2 의미 보존, FE 주석 `backend.ts BackendUpdateHqStoreRequest` 참조).
UpdateHqStoreRequest {
  name: string;                // 1..50 비-blank · null clear 불가(@nullable false)
  address: string | null;      // ≤200 · null=clear
  managerName: string | null;  // ≤50 · null=clear
  managerEmail: string | null; // @Email + ≤255 · null=clear
  managerPhone: string | null; // ≤30 free-form · null=clear
}

operationId updateHqStore. HQ_MANAGER-only. 응답 200 HqStoreDetailResponse(read-back, D7) — FE 가 detail invalidate 부담을 덜기 위해 갱신된 detail 을 그대로 반환한다. 검증 위반 시 400 HQ_STORE_INVALID_FIELD(jakarta validation 표준이 JsonNullable 안의 값을 unwrap 하지 않아 service 가 명시 검증해 단일 도메인 exception 으로 수렴). 타 본사·미존재 매장 → 404 STORE_NOT_FOUND 존재 은닉(D6). 변경 0 = no-op 200(audit row 도 생성 X — 의미 없는 row 차단). 변경된 필드만 hq_audit_log 1행 기록: HqAuditAction.HQ_STORE_UPDATED + HqAuditTargetType.STORE, detail.changedFields 키 배열 + before/after partial 스냅샷(개인정보 최소 노출). generated 훅 useUpdateHqStore. apps/space /admin/stores/[id] 매장 정보 섹션 헤더 [편집] 토글 → 인라인 폼(5 input + [취소]/[저장]) 으로 소비(hq-store-edit-form.tsx).

운영사 어드민 StoreDetailResponse(#036)과 비대칭 — HQ 매장 상세는 본인 본사 스코프라 hqId·hqName·plan·billingAnchorDay·managers[](다중) 가 없고, 대신 첫 점장 1건만 storeManager 로 노출한다(다중 발급 케이스는 운영사 어드민에서 관리).

CreateHqStoreRequest (#106 — POST /api/v1/hq/stores 본사 산하 매장 신규 등록, #084 F2 마감)

// generated `schemas/createHqStoreRequest.ts`. 단순 nullable 5 필드 — #105 `UpdateHqStoreRequest` 와 달리
// JsonNullable 가 아니다(신규 등록이라 `clear vs unchanged` 분기 불필요, null/생략 = 미입력 동일 의미).
// `type` 필드 없음 — 본사 유형 자동 결정(D2: INDEPENDENT 가상 본사 산하 → INDEPENDENT, 그 외 → FRANCHISE).
CreateHqStoreRequest {
  name: string;                  // 1..50 비-blank · @NotBlank 필수
  address?: string | null;       // ≤200 · 선택
  managerName?: string | null;   // ≤50 · 선택
  managerEmail?: string | null;  // @Email + ≤255 · 선택
  managerPhone?: string | null;  // ≤30 free-form · 선택
}

operationId createHqStore. HQ_MANAGER-only. 응답 201 HqStoreDetailResponse(read-back, D1) — FE 가 새 매장 상세 페이지(/admin/stores/{id})로 바로 push 하므로 추가 detail fetch 0. 검증 위반 시 400 HQ_STORE_INVALID_FIELD(jakarta validation @Valid + service 명시 가드). 정지(SUSPENDED) 본사는 등록 거부 → 403 AUTH_HQ_SUSPENDED (login 차단이 first line, service 가드 second line — D5). 동일 본사 내 name 중복 허용(분점 패턴, D6). 점장(STORE_MANAGER) 계정 발급은 분리(D3 — 운영자 또는 후속 F5). audit hq_audit_log 1행 기록: HqAuditAction.HQ_STORE_CREATED 신규(총 9종) + HqAuditTargetType.STORE 재사용, detail = {name, type, address, managerName} partial 스냅샷(편집 액션과 의미 분리, D7). impersonation 컨텍스트는 actorRole=OPERATOR_IMPERSONATING + impersonatedByEmail 동시 기록. generated 훅 useCreateHqStore. apps/space /admin/stores/new 단일 단계 폼(5 input + [취소]/[등록]) 으로 소비(hq-store-create-form.tsx) — 매장 목록 헤더 [+ 매장 등록] 진입.

BulkCreateHqStoreResponse / BulkRowResult / BulkRowStatus (#111 — POST /api/v1/hq/stores/bulk CSV 일괄 등록, #084 F3 마감)

// generated `schemas/bulkCreateHqStoreResponse.ts`·`bulkRowResult.ts`·`bulkRowResultStatus.ts`.
// 요청은 DTO 가 아니라 multipart `file`(CSV) — generated `BulkCreateHqStoresBody{ file: Blob }`.
// CSV 헤더 5컬럼 정확 일치: 매장명,주소,담당자명,담당자이메일,담당자전화 (UTF-8, BOM 제거, RFC4180).
BulkCreateHqStoreResponse {
  totalRows: number;            // 처리한 전체 데이터 행 수(헤더 제외)
  successCount: number;         // 등록 성공 행 수
  failureCount: number;         // 등록 실패 행 수
  results: BulkRowResult[];     // 행별 처리 결과(입력 순서, rowNumber asc)
}
 
BulkRowResult {
  rowNumber: number;                   // 헤더 제외 1-base 데이터 행 번호
  status: "SUCCESS" | "FAILED";        // BulkRowResultStatus — SUCCESS(등록됨)/FAILED(거부됨, 다른 행 무영향)
  storeId?: string | null;             // 등록된 매장 id(성공 시에만)
  storeName?: string | null;           // 성공=등록된 이름 / 실패=입력값(비면 null)
  errorCode?: string | null;           // 실패 사유 코드(주로 HQ_STORE_INVALID_FIELD)
  errorMessage?: string | null;        // 실패 사유 안내 문구(사용자 안전 문구)
}

operationId bulkCreateHqStores. HQ_MANAGER-only. consumes multipart/form-data(@RequestPart("file")). #106 단일 등록을 행 단위로 미러하되 MVP 5필드(점장 발급·영업시간 제외, 후속). 부분 실패 = 행별 독립 처리 + 성공분 커밋 + 결과 리포트(D2 — 한 행 실패가 전체 롤백 X). 행 검증은 #106 미러(HQ_STORE_INVALID_FIELD). type 자동 결정(D5)·중복 매장명 새 생성(D3)·audit 행별 HQ_STORE_CREATED(D6). 파일 자체 오류(헤더 불일치·빈 파일·파싱 불가)는 행 처리 전 400 HQ_STORE_BULK_INVALID_FILE(행 검증 실패와 구분 — 행 실패는 200 결과 리포트에 담긴다). 정지(SUSPENDED) 본사는 파일 처리 전 가드 403 AUTH_HQ_SUSPENDED(D5). generated 훅 useBulkCreateHqStores(multipart mutator — 음원 업로드 #041 선례 미러, FormData file 파트). apps/space /admin/stores/bulk 파일 업로드 + 결과 리포트(요약 + 실패 행 테이블) 로 소비(bulk-client.tsx) — 매장 목록 헤더 [CSV 일괄 등록] 진입. atom-grounded 임시(시안 부재 — design-debt §2).

HQ Mode 플레이리스트 조회 DTOs (#057 — apps/space /admin/playlists read-only)

본사(HQ_MANAGER)가 자기 본사 플레이리스트와 적용 현황을 조회만 한다(편집은 운영사 apps/admin 전용). hqId 는 토큰 주체 도출(요청 파라미터 없음, verifyHqScope — 타 본사 PL 비노출).

HqPlaylistListResponse / HqPlaylistListItem (#057 — GET /api/v1/hq/playlists)

// generated `schemas/hqPlaylistListResponse.ts`·`hqPlaylistListItem.ts`·`listHqPlaylistsParams.ts`
ListHqPlaylistsParams {
  q?: string;     // 이름 부분일치(대소문자 무시), 0..100
  page?: number;  // 0-base
  size?: number;  // 1..100 clamp
}
 
HqPlaylistListResponse {
  items: HqPlaylistListItem[];
  page: number;   // 0-base
  size: number;   // 1..100
  total: number;  // 필터 적용 후 전체 건수
}
 
HqPlaylistListItem {
  id: string;
  name: string;
  isDefault: boolean;         // (#058) 본사 기본 PL 여부 — read-only 배지(지정은 운영사 전용)
  libraryCount: number;       // 담긴 활성 라이브러리 수
  appliedStoreCount: number;  // 이 PL 을 활성으로 쓰는 본인 본사 매장 수
  status: PlaylistStatus;     // (#060) 파생 상태 EMPTY|UNUSED|ACTIVE|FALLBACK — read-only 상태 배지
  updatedAt: string;          // ISO-8601
}

operationId listHqPlaylists. 정렬 created_at DESC, id DESC 서버 고정. claim↔DB 불일치·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqPlaylists. apps/space /admin/playlists 가 본사 /stores(#051) 패턴(공용 ListToolbar/ListPagination)으로 소비 — read-only(생성/편집/삭제·적용·기본 지정 진입점 없음). 각 행에 읽기 전용 상태 배지(#060 — 사용 중/기본/미사용/비어있음)를 노출한다. status=FALLBACK 이면 상태 배지가 “기본” 을 표현하므로 별도 isDefault “기본” 배지는 숨겨 중복을 피하고, 그 외 status 인 기본 PL(예: 라이브러리 0 → EMPTY)은 isDefault “기본” 배지를 함께 노출한다(#058). 각 행은 상세(/admin/playlists/[id])로 링크.

HqPlaylistDetailResponse / HqPlaylistLibraryItem (#057 — GET /api/v1/hq/playlists/{id})

// generated `schemas/hqPlaylistDetailResponse.ts`·`hqPlaylistLibraryItem.ts`
HqPlaylistDetailResponse {
  id: string;
  name: string;
  isDefault: boolean;         // (#058) 본사 기본 PL 여부 — read-only 배지(헤더)
  libraryCount: number;       // 담긴 활성 라이브러리 수
  appliedStoreCount: number;  // 이 PL 을 활성으로 쓰는 본인 본사 매장 수
  status: PlaylistStatus;     // (#060) 파생 상태 EMPTY|UNUSED|ACTIVE|FALLBACK — read-only 상태 배지(헤더)
  libraries: HqPlaylistLibraryItem[];  // position 순(server 정렬)
  createdAt: string;          // ISO-8601
  updatedAt: string;          // ISO-8601
}
 
HqPlaylistLibraryItem {
  libraryId: string;
  name: string;
  libraryType: string;  // "AI" · "TRUST" (그 외 타입은 원문 노출)
  musicCount: number;   // 담긴 활성 음원 수
  position: number;     // 0-base
}

operationId getHqPlaylist. PL.hqId ≠ 주체 hqId 또는 미존재 → 404 PLAYLIST_NOT_FOUND(타 본사 PL 존재 은닉). generated 훅 useGetHqPlaylist. apps/space /admin/playlists/[id] 가 server-side(refresh-aware)로 fetch — 404 → notFound(), 5xx/네트워크 → 재시도 배너. read-only(추가/제거/순서/이름수정/삭제 진입점 없음).

운영사 어드민 PL DTO(#054)와 비대칭 — HQ 상세는 단일 본사라 소속 본사 그룹·커버리지·편집 액션이 없고, 대신 적용 매장 수(자기 본사 매장 중 활성 사용)를 집계해 노출한다.

HQ Mode 라이브러리 조회 DTOs (#080 — apps/space /admin/libraries read-only)

본사(HQ_MANAGER)가 자기 본사 PL 에 담을 후보 라이브러리를 조회만 한다(편집·음원 할당은 운영사 apps/admin 전용). 가시 범위는 운영사가 만든 모든 라이브러리(본사 PL #057 과 동일 패턴 — plan/type mismatch 게이트는 후속 #060 F1).

HqLibraryListResponse / HqLibraryListItem (#080 — GET /api/v1/hq/libraries)

// generated `schemas/hqLibraryListResponse.ts`·`hqLibraryListItem.ts`·`listHqLibrariesParams.ts`
ListHqLibrariesParams {
  q?: string;            // 이름 부분일치(대소문자 무시), 0..100
  type?: "AI" | "TRUST"; // 라이브러리 타입 필터(생략=전체)
  page?: number;         // 0-base
  size?: number;         // 1..100 clamp
}
 
HqLibraryListResponse {
  items: HqLibraryListItem[];
  page: number;   // 0-base
  size: number;   // 1..100
  total: number;  // 필터 적용 후 전체 건수
}
 
HqLibraryListItem {
  id: string;
  name: string;
  type: "AI" | "TRUST";  // 라이브러리는 단일 타입 묶음(음원 enforcement #059)
  trackCount: number;     // 소속 활성 음원 수
  playlistCount: number;  // 이 라이브러리를 사용 중인 본인 본사 PL 수(타 본사 PL 제외)
  createdAt: string;      // ISO-8601
  updatedAt: string;      // ISO-8601
}

operationId listHqLibraries. 정렬 name ASC 서버 고정. claim↔DB 불일치·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useListHqLibraries. apps/space /admin/libraries 가 본사 /admin/playlists(#057) 패턴(공용 ListToolbar/ListPagination)으로 소비 — read-only(생성/편집/삭제·음원 할당 진입점 없음). 행 컬럼: 이름·타입 배지(AI=info/TRUST=success)·음원 수·PL 사용 수·수정 시각(KST). 상세 view 는 후속 F1.

운영사 라이브러리 DTO(#053)와 비대칭 — HQ 목록은 본인 본사 PL 사용 수(playlistCount)만 집계(타 본사 PL 제외, 본사가 후보 선별 시 자기 PL 활용도 확인용)·생성/편집/음원 할당 액션 없음.

HqLibraryDetailResponse / HqLibraryMusicListResponse / HqLibraryMusicListItem (#107 — GET /api/v1/hq/libraries/{id} · .../{id}/music)

// generated `schemas/hqLibraryDetailResponse.ts`·`hqLibraryMusicListResponse.ts`·
//           `hqLibraryMusicListItem.ts`·`listHqLibraryMusicParams.ts`·`hqLibraryDetailResponseType.ts`
HqLibraryDetailResponse {
  id: string;
  name: string;
  type: "AI" | "TRUST";  // HqLibraryDetailResponseType — 라이브러리는 단일 타입 묶음(상세 헤더 배지 한 곳)
  trackCount: number;     // 소속 활성 음원 수
  playlistCount: number;  // 이 라이브러리를 사용 중인 본인 본사 활성 PL 수(타 본사 PL 제외)
  createdAt: string;      // ISO-8601
  updatedAt: string;      // ISO-8601
}
 
ListHqLibraryMusicParams {
  page?: number;  // 0-base
  size?: number;  // 1..100 clamp (기본 20)
}
 
HqLibraryMusicListResponse {
  items: HqLibraryMusicListItem[];
  page: number;   // 0-base
  size: number;   // 1..100
  total: number;  // 라이브러리에 담긴 활성 음원 전체 건수
}
 
HqLibraryMusicListItem {  // 트랙(담긴 음원) 행 — 할당 시각 DESC
  id: string;
  title: string;
  durationSeconds: number;  // 곡 길이(초) — FE 에서 mm:ss(또는 h:mm:ss) 포맷
  createdAt: string;        // ISO-8601 (할당 시각)
  updatedAt: string;        // ISO-8601
}

operationId getHqLibrary·listHqLibraryMusic. 라이브러리는 운영사 공유 모델(hqId 컬럼 없음)이라 verifyHqScope(403 가드)만 통과하면 모든 활성 라이브러리 조회 가능 — PL 상세(#057)의 hqId != → 404 은닉 분기는 없다(#080 §D3). 미존재/soft-deleted → 404 LIBRARY_NOT_FOUND. claim↔DB 불일치·role/소속 불일치 → 403 PRINCIPAL_SCOPE_MISMATCH · 미인증 401. generated 훅 useGetHqLibrary·useListHqLibraryMusic. apps/space /admin/libraries/[id] 가 메타는 server fetch(404→notFound()), 트랙은 client query(useListHqLibraryMusic, 페이지네이션)로 소비 — read-only(음원 추가/제거·이름 수정·삭제·체크박스 없음). 트랙 행 컬럼: 제목·길이(mm:ss)·추가일(KST). audioUrl·트랙별 타입 배지 미포함(라이브러리 단일 타입).

운영사 라이브러리 상세(#053 getLibrary+listLibraryMusic)와 idiom 일관, 단 본사 view 는 read-only — 운영사의 음원 담기/제거·이름 수정·삭제 mutation 진입점이 없다.

HQ Mode TTS 안내방송 DTOs (#061 — apps/space /admin/announcements)

본사(HQ_MANAGER)가 텍스트+voice+톤 프리셋으로 합성한 안내방송. 생성=합성(동기 완료)·목록·상세·수정(조건부 재합성, #062)·삭제. voice 는 TtsVoice 5종(enums) — generated 노출 enum CreateTtsAnnouncementRequestVoice/UpdateTtsAnnouncementRequestVoice/ TtsAnnouncementListItemVoice/TtsAnnouncementDetailResponseVoice. 톤 프리셋은 TtsTonePreset 6종(enums, #063) — voice별 허용 집합이 다르며 모든 voice 가 NORMAL(기본) 지원.

CreateTtsAnnouncementRequest (#061·#063 — POST /api/v1/hq/announcements)

// generated `schemas/createTtsAnnouncementRequest.ts`
CreateTtsAnnouncementRequest {
  title: string;  // @maxLength 255 — 목록/식별용 제목
  text: string;   // @NotBlank @maxLength 1000 — 합성할 본문
  voice: CreateTtsAnnouncementRequestVoice;  // TtsVoice 5종(SHEAN·WOOSUNG·CYRUS·AERAN·SEUNGA)
  tonePreset?: CreateTtsAnnouncementRequestTonePreset;  // TtsTonePreset 6종, @nullable, 기본 NORMAL (#063)
  tempo?: number;  // (#139) @minimum 0.5 @maximum 2 @nullable — 발화 속도 배수, 미지정 시 서버 기본 1.0
}

FE 검증은 OpenAPI 제약과 일치(frontend.md §8) — 빈 title/text 는 클라이언트에서 submit disable. voice 라벨(한글)은 합성 전이라 BE 표시명을 못 받으므로 FE tts-voice-meta.ts 보유(value→라벨). 톤 프리셋(#063): voice 연동 select — listHqTtsVoices(TtsVoiceListResponse)로 voice별 허용 톤을 받아 선택 voice 의 presets 만 노출, voice 변경 시 허용 밖이면 NORMAL 로 리셋(UI 가 위반 차단). 발화 속도(tempo, #139): voice·톤과 동일한 방송별 파라미터. FE 는 0.5~2.0(step 0.1) select 로 노출(기본 1.0=1.0× (기본)), value→payload 직결. 범위 밖이면 400(서버 검증).

UpdateTtsAnnouncementRequest (#062·#063 — PUT /api/v1/hq/announcements/{id})

// generated `schemas/updateTtsAnnouncementRequest.ts`
UpdateTtsAnnouncementRequest {
  title: string;  // @maxLength 255
  text: string;   // @maxLength 1000 — 기존과 다르면 재합성된다
  voice: UpdateTtsAnnouncementRequestVoice;  // TtsVoice 5종(동일 enum)
  tonePreset?: UpdateTtsAnnouncementRequestTonePreset;  // TtsTonePreset 6종, @nullable (#063)
  tempo?: number;  // (#139) @minimum 0.5 @maximum 2 @nullable — 기존과 다르면 재합성된다
}

전체 교체(PUT). text·voice·tonePreset·tempo 중 하나라도 기존과 다르면 BE 가 재합성(같은 blob url 덮어쓰기 + durationSeconds 갱신), title 만 바뀌면 재합성 skip(§5-1) — 판정은 BE, FE 는 동일 “합성 중…” pending. 성공 응답은 TtsAnnouncementDetailResponse(200, tempo: number 포함). FE 는 행 [수정] 시 상세를 fetch 해 초기값(title·text·voice·tonePreset·tempo)을 채운 create/edit 겸용 다이얼로그로 제출(useUpdateHqTtsAnnouncement). 재생 캐시버스터로 응답 updatedAtaudioUrl?v= 에 사용.

TtsVoiceListResponse / TtsVoiceOption / TtsTonePresetOption (#063 — GET /api/v1/hq/tts-voices)

// generated `schemas/ttsVoiceListResponse.ts`·`ttsVoiceOption.ts`·`ttsTonePresetOption.ts`
TtsVoiceListResponse {
  voices: TtsVoiceOption[];  // 5종, voice 별 허용 톤 프리셋 매핑
}
 
TtsVoiceOption {
  voice: TtsVoiceOptionVoice;       // TtsVoice
  voiceDisplayName: string;         // voice 한글 표시명
  presets: TtsTonePresetOption[];   // 이 voice 가 지원하는 톤(항상 NORMAL 포함)
}
 
TtsTonePresetOption {
  preset: TtsTonePresetOptionPreset;  // TtsTonePreset
  presetDisplayName: string;          // 톤 한글 표시명(BE 단일 소스)
}

생성·수정 다이얼로그가 열릴 때 1회 조회해 톤 select 를 voice 에 연동한다(useListHqTtsVoices). voices 응답이 아직/실패면 FE 는 폴백으로 NORMAL 만 노출(tts-voice-meta.ts 정적 라벨)해 폼이 깨지지 않는다.

TtsAnnouncementDetailResponse (#061·#063 — POST·PUT·GET .../{id} 성공)

// generated `schemas/ttsAnnouncementDetailResponse.ts`
TtsAnnouncementDetailResponse {
  id: string;
  title: string;
  text: string;                 // 합성에 쓴 원문
  voice: TtsAnnouncementDetailResponseVoice;  // TtsVoice
  voiceDisplayName: string;     // voice 한글 표시명(BE 단일 소스)
  tonePreset: TtsAnnouncementDetailResponseTonePreset;  // TtsTonePreset (#063)
  tonePresetDisplayName: string;  // 톤 한글 표시명(BE 단일 소스, #063)
  audioUrl: string;             // Azure public blob — 직접 재생
  durationSeconds?: number | null;  // Typecast 가 줄 때만(nullable)
  createdAt: string;            // ISO-8601
  updatedAt: string;            // ISO-8601
}

생성(201)·상세(200) 공통 응답. FE 목록 행은 [재생] 시 상세를 lazy fetch 해 audioUrl<audio controls autoPlay src={audioUrl}> 재생(N+1 회피).

TtsAnnouncementListResponse / TtsAnnouncementListItem (#061·#063·#066·#097 — GET /api/v1/hq/announcements)

// generated `schemas/ttsAnnouncementListResponse.ts`·`ttsAnnouncementListItem.ts`
TtsAnnouncementListResponse {
  items: TtsAnnouncementListItem[];
  page: number;   // 0-base
  size: number;   // 1..100 clamp
  total: number;  // 활성(soft-delete 제외) 전체 건수
}
 
TtsAnnouncementListItem {
  id: string;
  title: string;
  voice: TtsAnnouncementListItemVoice;  // TtsVoice
  voiceDisplayName: string;             // voice 한글 표시명
  tonePreset: TtsAnnouncementListItemTonePreset;  // TtsTonePreset (#063)
  tonePresetDisplayName: string;        // 톤 한글 표시명 (#063)
  durationSeconds?: number | null;      // null 가능
  createdAt: string;                    // ISO-8601
  updatedAt: string;                    // ISO-8601
  dispatchCount: number;                // (#066) 누적 송출 row 수(=중복 송출 포함). 0=미송출. **고유 매장 수 아님** — 같은 매장에 N 번 송출하면 N. 매장 그룹핑은 후속(F).
  lastDispatchedAt?: string | null;     // (#066) 가장 최근 송출 시각 ISO-8601(`MAX(announcement_dispatch.created_at)`). null=미송출.
  lastDispatchStoreCount?: number | null; // (#097) 마지막 송출 호출의 distinct 매장 수(fan-out 사이즈). null=미송출. **누적 아님** — 마지막 1회 호출만 카운트. audit_id 그룹핑(V28+) + legacy created_at fallback. UI 는 같은 셀에 `(N매장)` 보조 표기.
}

#066 — list 송출 요약: 본사가 행에서 “몇 번 송출됐는지·마지막이 언제인지”를 바로 확인하고 송출 이력 다이얼로그(#065) 진입 결정을 가볍게 한다. BE 는 LEFT JOIN announcement_dispatch d ON d.announcement_id = t.id AND d.hq_id = t.hq_id + GROUP BY 평탄 projection 한 쿼리(D2·D3 V26 인덱스 재활용). 정렬·페이징·검색·source=‘HQ’ 격리는 기존 그대로(D4·D10). 상세 DTO 에는 추가하지 않는다(이력 endpoint 가 같은 시그널을 더 풍부하게 제공 — D12).

#097 — 마지막 송출 fan-out 사이즈: list 행 단위로 “이번 마지막 송출은 N매장 대상” 직관 제공. BE 는 LEFT JOIN LATERAL last_call (LIMIT 1) 으로 마지막 dispatch row 의 audit_id(V28+ 호출 단위 식별자)

  • created_at(legacy fallback) 을 announcement 당 1회 산출, scalar subquery 가 같은 호출 row 들의 distinct store_id 를 CAST(… AS int) 한다. 같은 ms tick 동률 호출 시 tiebreak 가 약하지만 audit_id 그룹핑 덕에 두 호출 fan-out 이 섞이지 않고 한 호출로 수렴. FE 는 lastDispatchedAt 셀 같은 자리에 (N매장) 보조 표기, 미송출/legacy null 은 표시 없음(noise 차단).

list item 에는 audioUrl 이 없다(상세에만) — 목록 행 재생은 상세 endpoint 를 lazy 호출해 audioUrl 을 얻는다. operationId listHqTtsAnnouncements. query q?·page·size. 정렬 created_at DESC 서버 고정. hqId 토큰 주체 도출(타 본사 비노출). 삭제는 deleteHqTtsAnnouncement (204 soft-delete), 응답 body 없음.

DispatchRequest / DispatchResponse (송출 슬라이스 — POST /api/v1/hq/announcements/{id}/dispatch)

// generated `schemas/dispatchRequest.ts`·`dispatchResponse.ts`·`dispatchRequestTarget.ts`
DispatchRequest {
  target: DispatchRequestTarget;            // "ALL" | "STORES" | "REGION" (#144)
  storeIds?: (string | null)[] | null;       // STORES 일 때 필수, 모두 본인 본사 산하 (ALL/REGION 이면 무시). 크기 ≤1000 은 target 무관 적용(페이로드 가드)
  region?: Region | null;                    // (SPEC #144) REGION 일 때 필수(시/도 enum). 누락 시 400 DISPATCH_REGION_REQUIRED. ALL/STORES 면 무시. DispatchRequestRegion
  scheduledAt?: string | null;               // (SPEC #078) ISO-8601. null=즉시 송출(기존 동작·PENDING fan-out), non-null=예약(SCHEDULED row 적재 → 디스패처 전이). 과거=400 DISPATCH_SCHEDULED_AT_PAST · 1년 초과=400 DISPATCH_SCHEDULED_AT_TOO_FAR
}
 
DispatchResponse {
  dispatchedCount: number;   // 생성된 송출 row 수 (매장 0건이면 0)
  dispatchIds: string[];     // 생성된 송출 id 목록 (매장당 1개)
}

operationId dispatchHqTtsAnnouncement(201). 매장당 1개 PENDING(즉시) 또는 SCHEDULED(예약) announcement_dispatch row 를 fan-out. target=ALL=산하 매장 전체(폐점 매장 제외·정지/비활성 포함), STORES=지정 매장 (빈 배열 → 400 검증 오류(minItems:1) / storeIds 생략(null) → 400 DISPATCH_INVALID_TARGET / 미존재·타 본사 혼입 → 404 TTS_ANNOUNCEMENT_NOT_FOUND 전체 은닉, 부분 송출 없음). target=REGION(#144)=region(시/도) 으로 hqId AND region=:region 매장에 fan-out (region 누락 → 400 DISPATCH_REGION_REQUIRED · 해당 region 매장 0건 → dispatchedCount=0). 중복 송출 허용. 미존재 안내방송 → 404 TTS_ANNOUNCEMENT_NOT_FOUND. 예약 송출(SPEC #078): scheduledAt non-null 이면 row 가 status=SCHEDULED, scheduled_at=scheduledAt 으로 적재되고 백그라운드 HqDispatchScheduler(@Scheduled(cron='0 * * * * *')) 가 도래 시 원자 UPDATE 로 PENDING 전이 → 점장 player 가 자연 픽업. DispatchRequestTarget enum(enums) · DispatchStatus enum(enums).

CreateDispatchScheduleRequest / DispatchScheduleResponse (반복 송출 예약 — POST/GET /api/v1/hq/dispatch-schedules, #139 확장)

// generated `schemas/createDispatchScheduleRequest.ts`·`dispatchScheduleResponse.ts`·`*Frequency.ts`·`*Target.ts`
CreateDispatchScheduleRequest {
  announcementId: string;                    // 반복 송출할 안내방송 id (본인 본사 활성 HQ 출처)
  target: CreateDispatchScheduleRequestTarget;  // "ALL" | "STORES" | "REGION" (#144)
  storeIds?: (string | null)[] | null;       // STORES 일 때 필수(1~1000, 모두 산하). ALL/REGION 이면 무시
  region?: Region | null;                    // (#144) REGION 일 때 필수(시/도). 누락 시 400 DISPATCH_REGION_REQUIRED. ALL/STORES 면 무시. CreateDispatchScheduleRequestRegion
  frequency: CreateDispatchScheduleRequestFrequency;  // (#139) DAILY|WEEKLY|HOURLY|EVEN_HOURS|ODD_HOURS
  byWeekday?: string | null;                 // WEEKLY 필수 — 요일 CSV(MON~SUN, @maxLength 32). 그 외 무시
  startHour?: number | null;                 // (#139) 시각형 빈도 운영시간 시작 hour. @minimum 0 @maximum 23, ≤endHour. 시각형이면 필수, DAILY/WEEKLY 무시
  endHour?: number | null;                   // (#139) 시각형 빈도 운영시간 종료 hour. @minimum 0 @maximum 23, ≥startHour. 시각형이면 필수
  slotTime: string;                          // 'HH:MM' KST 5분 슬롯(분 5배수). @maxLength 5. 시각형은 분(MM)만 매시 :MM 오프셋(시 무시)
  startsOn: string;                          // 전개 시작일 (KST date, 포함)
  endsOn?: string | null;                    // 종료일(포함). null=무한. 지정 시 ≥startsOn
}
 
DispatchScheduleResponse {  // 동일 필드 + id·timezone·status·createdAt
  id: string; announcementId: string;
  target: DispatchScheduleResponseTarget; storeIds: string[];   // REGION/ALL 이면 storeIds 빈 배열
  region?: Region | null;                    // (#144) REGION 이면 시/도, ALL/STORES 면 null. DispatchScheduleResponseRegion
  frequency: DispatchScheduleResponseFrequency;  // (#139) 5종
  byWeekday?: string | null;                 // WEEKLY 정규화 CSV, 그 외 null
  startHour?: number | null;                 // (#139) 시각형이면 운영시간, DAILY/WEEKLY 면 null
  endHour?: number | null;                   // (#139) 동일
  slotTime: string; startsOn: string; endsOn?: string | null;
  timezone: string;                          // KST 고정
  status: DispatchScheduleResponseStatus;    // ACTIVE → CANCELED 단방향
  createdAt: string;
}

빈도↔요일·빈도↔운영시간 불일치·slotTime 형식 위반·endsOn<startsOn·운영시간 범위(startHour>endHour·0-23 밖) → 400 DISPATCH_SCHEDULE_INVALID. 시각형(HOURLY/EVEN_HOURS/ODD_HOURS)이면 startHour·endHour 필수이고 slotTime 의 시(HH)는 무시(분 MM만 매시 오프셋) — FE 는 00:MM 으로 전송. 백그라운드 디스패처가 14일 윈도우로 SCHEDULED dispatch 전개(시각형은 하루 N건). DispatchScheduleFrequency enum(enums). 상세는 반복 송출 예약.

rate limit: 반복 예약 생성(POST /api/v1/hq/dispatch-schedules, createHqDispatchSchedule)·취소(PATCH .../{id}/cancel, cancelHqDispatchSchedule)는 본사 제어 액션으로 HQ_CONTROL 그룹 30/분(APP_RATE_LIMIT_HQ_CONTROL_PER_MINUTE) rate limit 대상 — 초과 시 429 RATE_LIMITED + Retry-After(error-codes).

DispatchCalendarResponse / DispatchCalendarEvent (송출 캘린더 — GET /api/v1/hq|store/dispatch-calendar)

// generated `schemas/dispatchCalendarResponse.ts`·`dispatchCalendarEvent.ts`·`dispatchCalendarEventKind.ts`·`dispatchCalendarEventStatus.ts`
DispatchCalendarResponse {
  from: string;   // 조회 시작일(KST day, 포함) — 요청 echo
  to: string;     // 조회 종료일(KST day, 포함) — 요청 echo
  events: DispatchCalendarEvent[];   // effective time ASC → id ASC 결정적·0건이면 빈 배열
}
DispatchCalendarEvent {
  dispatchId: string;
  scheduledAt: string;        // 캘린더 배치 시각(UTC ISO). 예약=scheduled_at·즉시=created_at COALESCE 파생.
                              // FE 가 KST day 로 변환해 셀에 배치.
  storeId: string;
  storeName?: string | null;  // 본사 캘린더만 채워짐(점장은 본인 매장 고정 → null)
  announcementTitle: string;
  kind: DispatchCalendarEventKind;     // "EMERGENCY" | "HQ_ANNOUNCEMENT" | "STORE_BROADCAST"
  status: DispatchCalendarEventStatus; // "SCHEDULED" | "PENDING" | "PLAYED" | "CANCELED"
  isEmergency: boolean;
  scheduleId?: string | null;          // 반복 전개분이면 규칙 id, 1회성은 null
}

본사·점장이 같은 DTO 를 공유한다(점장은 storeName null). query from·to(KST day, YYYY-MM-DD)는 최대 62일 — 초과/역순 → 400 DISPATCH_CALENDAR_INVALID_RANGE. FE 월 그리드는 보는 달 그리드 범위 (≤42칸)만 fetch 해 cap 안에서 단일 호출한다. kind/status enum 은 enums. 상세는 본사 캘린더·점장 캘린더.

DispatchHistoryResponse / DispatchHistoryItem / DispatchHistoryAggregate (#065 — GET /api/v1/hq/announcements/{id}/dispatches)

// generated `schemas/dispatchHistoryResponse.ts`·`dispatchHistoryItem.ts`·`dispatchHistoryAggregate.ts`·`dispatchHistoryItemStatus.ts`·`dispatchHistoryItemActorRole.ts`
DispatchHistoryResponse {
  items: DispatchHistoryItem[];           // 현재 페이지 송출 이력 행
  page: number;                           // 0-base
  size: number;                           // 1..100 clamp
  total: number;                          // 해당 announcement scope 의 전체 송출 row 수
  aggregate: DispatchHistoryAggregate;    // 페이지 무관 전체 카운트
}
 
DispatchHistoryItem {
  dispatchId: string;                     // 송출 id (announcement_dispatch.id)
  storeId: string;                        // 수신 매장 id
  storeName: string;                      // 수신 매장 이름 (BE 단일 소스 — store join)
  status: DispatchHistoryItemStatus;      // (#077·#078 4종) "SCHEDULED" | "PENDING" | "PLAYED" | "CANCELED"
  createdAt: string;                      // 송출 row 생성 시각 (ISO-8601, 예약 등록 시각 = SCHEDULED 의 등록 시각)
  playedAt?: string | null;               // 재생(ack) 시각 (ISO-8601). SCHEDULED/PENDING/CANCELED 동안 null
  scheduledAt?: string | null;            // (SPEC #078) 예약 송출 시각 (ISO-8601). null=즉시 송출 row · non-null=예약 송출 row(SCHEDULED 또는 디스패처 전이 후 PENDING/PLAYED/CANCELED — 시각 정보 그대로 보존). FE 가 SCHEDULED 행 "송출 시각" 셀에 노출
  // (#071) 송출 수행자 actor 정보 — `announcement_dispatch.audit_id` (V28) FK 로 `hq_audit_log` 와 LEFT JOIN 한 스냅샷.
  actorEmail?: string | null;             // (#071) 송출 수행자 이메일 스냅샷. HQ_MANAGER 면 본사 매니저 본인, OPERATOR_IMPERSONATING 면 위장 대상 본사 매니저. V28 이전 row 또는 audit 누락 시 null
  actorRole?: DispatchHistoryItemActorRole;// (#071) "HQ_MANAGER" | "OPERATOR_IMPERSONATING". V28 이전 row 는 null
  impersonatedByEmail?: string | null;    // (#071) 위장 운영자 이메일 스냅샷. OPERATOR_IMPERSONATING 일 때만 채워짐 — 직접 송출(HQ_MANAGER)·V28 이전 row 는 null
}
 
DispatchHistoryAggregate {
  total: number;                          // 전체 송출 row 수
  played: number;                         // 재생 완료(PLAYED) row 수
  pending: number;                        // 미재생(PENDING) row 수
  scheduled: number;                      // (SPEC #078) 예약 대기(SCHEDULED) row 수 — 디스패처 전이 전 row 만 카운트
  distinctStoreCount: number;             // (SPEC #075) 고유 매장 수 — COUNT(DISTINCT storeId), 매장 0건이면 0
}

operationId listHqTtsAnnouncementDispatches(200). 정렬 created_at DESC, id DESC 서버 고정 (결정적). 중복 송출 허용 — 같은 매장에 여러 번 송출되면 dispatchId 가 다른 별개 row 로 row 단위 노출(매장 그룹핑 요약은 후속 F). aggregate 는 현재 페이지가 아니라 그 안내방송 scope 의 전체 카운트 라 페이지 이동에도 같은 숫자다. 본인 본사 활성 HQ 출처 안내방송만 — 타 본사·미존재·삭제·STORE_BROADCAST 출처는 404 TTS_ANNOUNCEMENT_NOT_FOUND(존재 은닉). FE 는 apps/space /admin/announcements 행 [이력] 또는 송출 결과 배너 [이력 보기] → DispatchHistoryDialog(집계 헤더 + 매장 행 표 + 페이지네이션). status 는 기존 DispatchStatus(enums) 와 동일 값 도메인 — generated 타입은 endpoint scope DispatchHistoryItemStatus(value 동일).

actor 3 필드 nullable 이유 (#071): ① announcement_dispatch.audit_id 는 V28 신규 컬럼이라 V28 이전 row 는 백필 안 함(announcement_id + occurred_at 범위 백필이 부정확) → 해당 row 의 세 필드 모두 null. ② audit_id 의 FK 는 ON DELETE SET NULL 이라 audit row 가 후속 삭제되면 자동으로 null 화. ③ V28 이후 신규 dispatch 는 같은 트랜잭션 내 audit INSERT 가 항상 짝지어지므로(#067 원자성 패턴) 실 운영에서 null 노출은 없음. DispatchHistoryItemActorRole 값 도메인은 HqAuditActorRole 과 동일(HQ_MANAGER · OPERATOR_IMPERSONATING) — generated 타입은 endpoint scope.

HqAuditListResponse / HqAuditItem (#067 — GET /api/v1/hq/audit/dispatches)

// generated `schemas/hqAuditListResponse.ts`·`hqAuditItem.ts`·`hqAuditItemAction.ts`·`hqAuditItemActorRole.ts`·`hqAuditItemTargetType.ts`·`listHqAuditDispatchesParams.ts`·`listHqAuditDispatchesAction.ts`
HqAuditListResponse {
  items: HqAuditItem[];                    // 현재 페이지 본사 감사 행
  page: number;                            // 0-base
  size: number;                            // 1..100 clamp
  total: number;                           // 본사 scope 의 전체 감사 행 수
}
 
HqAuditItem {
  id: string;                              // 감사 행 id (hq_audit_log.id)
  occurredAt: string;                      // 발생 시각 (ISO-8601 UTC)
  actorEmail: string;                      // 행위자 본사 매니저 이메일 (스냅샷)
  actorRole: HqAuditItemActorRole;         // "HQ_MANAGER" | "OPERATOR_IMPERSONATING"
  impersonatedByEmail?: string | null;     // 임퍼소네이션 중인 운영자 이메일 (위장 컨텍스트에서만, 그 외 null)
  action: HqAuditItemAction;               // "HQ_ANNOUNCEMENT_DISPATCHED" (현재 1종, 후속 확장)
  targetType: HqAuditItemTargetType;       // "TTS_ANNOUNCEMENT"
  targetId?: string | null;                // 대상 id (예: 안내방송 id). null 가능
  targetLabel?: string | null;             // 대상 라벨 스냅샷(안내방송 제목). null 가능
  detail?: string | null;                  // 액션 상세 (예: "target=ALL·count=3·storeIds=[…]"). 1024자 cap
}
 
ListHqAuditDispatchesParams {              // GET /api/v1/hq/audit/dispatches query
  from?: string;                           // 기간 시작 (occurred_at 기준, ISO-8601 date-time)
  to?: string;                             // 기간 종료 (ISO-8601 date-time)
  actorAccountId?: string;                 // 수행 본사 매니저 계정 id (UUID)
  action?: ListHqAuditDispatchesAction;    // HqAuditItemAction 과 동값 (현재 1종)
  targetId?: string;                       // 대상 id (예: 안내방송 id)
  q?: string;                              // 대상 라벨·상세 부분 검색 (대소문자 무시, ≤100)
  page?: number;                           // 0-base
  size?: number;                           // 1..100 clamp
}

operationId listHqAuditDispatches(200). 정렬 occurred_at DESC, id DESC 서버 고정(결정적). hqId 는 토큰 주체 도출(자기 본사만 — WHERE hq_id = :hqId 강제, 타 본사 격리). impersonation 송출(운영자 위장) 행은 actorRole=OPERATOR_IMPERSONATING + impersonatedByEmail 동시 노출 — 원본 운영자와 위장 본사 매니저 둘 다 추적. impersonatedByEmailactorRole=HQ_MANAGER 시 null (스키마 nullable). 기록은 HqAnnouncementDispatchService.dispatch 트랜잭션 내 HqAuditService.record — append-only(audit INSERT 실패 시 dispatch 도 함께 롤백, 원자성). FE 는 apps/space /admin/audit 페이지(필터·페이지네이션 client state, generated useListHqAuditDispatches). enum 도메인은 HqAuditAction · HqAuditActorRole · HqAuditTargetType.

HqAuditActorListResponse / HqAuditActorOption (#069 — GET /api/v1/hq/audit/actors)

// generated `schemas/hqAuditActorListResponse.ts`·`hqAuditActorOption.ts`·`hqAuditActorOptionRole.ts`
HqAuditActorListResponse {
  items: HqAuditActorOption[];             // 본인 본사 audit 등장 actor 옵션 (상한 200)
}
 
HqAuditActorOption {
  accountId: string;                       // actor 계정 id (UUID — HQ_MANAGER 면 본사 매니저, OPERATOR_IMPERSONATING 이면 원본 운영자)
  email: string;                           // actor 이메일 스냅샷 (같은 actor 가 이메일을 바꿨으면 최신 audit 행 기준)
  role: HqAuditActorOptionRole;            // "HQ_MANAGER" | "OPERATOR_IMPERSONATING"
  occurrenceCount: number;                 // 해당 actor 의 audit 행 총 수 (정렬·label 보조)
}

operationId listHqAuditActors(200). envelope 없음(상한 200 라 페이지네이션 미적용). 정렬 occurrenceCount DESC, email ASC 서버 고정 · 상한 200(초과 시 상위만, 모자라면 FE 자유 입력 fallback). 쿼리 = hq_audit_log UNION ALL projection(HQ_MANAGER actor 와 OPERATOR_IMPERSONATING 의 원본 운영자 둘 다 추출) + outer GROUP BY(accountId, role) → email 은 같은 actor 의 최신 audit 스냅샷. hqId 는 토큰 주체 도출(자기 본사만 — WHERE hq_id = :hqId 강제, 타 본사 격리). 기존 V27 인덱스 idx_hq_audit_actor_account_id 재사용(마이그레이션·추가 인덱스 0). FE 는 apps/space /admin/audit 행위자 select(자유 입력 → select 전환, 옵션 0건이면 disabled + “감사 로그가 없습니다.”). enum 도메인은 HqAuditActorRole 와 같은 값을 공유한다.

HQ Commercial DTOs (#093 — apps/space /admin/commercials)

본사(HQ_MANAGER) 가 CM송(광고/공지 음원) 을 직접 등록·조회·편집·삭제하는 도메인. 가시 범위는 본인 본사의 활성 row 만(WHERE hq_id = :hqId AND deleted_at IS NULL 강제, BE D3). 본사 안내방송(#061) 이 “TTS 합성으로 매장에 송출” 한다면 CM송은 “사전 업로드 음원을 점장 player 큐 사이에 사이클 삽입” 하는 다른 도메인이다(F1 후속에서 재생기 연동). 본 슬라이스는 백본 + 본사 관리 UI 만 — 점장 player 삽입·CM 라이브러리·audit 은 F1·F2·F3 후속.

HqCommercialListItem (#093 — GET /api/v1/hq/commercials item · GET .../{id} 동일 필드)

// generated `schemas/hqCommercialListItem.ts`·`hqCommercialDetailResponse.ts` (동일 필드)
HqCommercialListItem {
  id: string;
  title: string;             // 표시명 (1~200자)
  audioUrl: string;          // Azure blob 등의 공개 오디오 URL (≤2048자)
  durationSeconds: number;   // 오디오 길이(초) — 1..3600
  isActive: boolean;         // 활성 여부 — F1 점장 player 사이클 삽입 대상 토글
  createdAt: string;         // ISO-8601
  updatedAt: string;         // ISO-8601
}

목록·상세 동일 필드(generated 는 응답별로 HqCommercialListItem / HqCommercialDetailResponse 두 타입 emit, 필드 값은 같음). FE 목록 행은 제목 셀 클릭으로 /admin/commercials/{id} 네비 → 상세에서 <audio controls src={audioUrl}> 로 미리듣기(별도 lazy fetch 불필요 — list item 에 이미 audioUrl 포함, TTS 안내방송 #061 와 다른 점).

HqCommercialListResponse (#093 — 페이지네이션 응답)

// generated `schemas/hqCommercialListResponse.ts`
HqCommercialListResponse {
  items: HqCommercialListItem[];
  page: number;   // 0-base
  size: number;   // 1..100 clamp
  total: number;  // 필터 적용 후 전체 건수(soft-delete 제외)
}

operationId listHqCommercials. query q?·isActive?·page·size. 정렬 created_at DESC, id ASC 서버 고정(결정적, BE D5). hqId 토큰 주체 도출 — 타 본사 비노출.

ListHqCommercialsParams (#093 — query · generated)

// generated `schemas/listHqCommercialsParams.ts`
ListHqCommercialsParams = {
  q?: string;          // 제목 부분일치(대소문자 무시), ≤100
  isActive?: boolean;  // true/false. 미지정 시 전체
  page?: number;       // 0-base
  size?: number;       // 1..100 clamp
};

CreateHqCommercialRequest (#093 — POST /api/v1/hq/commercials)

// generated `schemas/createHqCommercialRequest.ts`
CreateHqCommercialRequest {
  title: string;            // 1~200
  audioUrl: string;         // ≤2048 (URL 형식)
  durationSeconds: number;  // 1~3600
}

201 + HqCommercialDetailResponse. 생성 시 isActive=true 기본(F1 사이클 삽입 대상). audioUrl 은 FE 가 음원 파일 업로드(별도 hook 후속) 후 받은 Azure blob URL — 본 슬라이스는 음원 업로드 UI 미포함, 사용자가 공개 접근 URL 을 직접 입력한다(SPEC §D9 가드). hqId·createdAt·updatedAt 은 BE 가 토큰 주체·now() 로 채운다(요청 본문 X — 타 본사 격리).

UpdateHqCommercialRequest (#093 — PATCH /api/v1/hq/commercials/{id})

// generated `schemas/updateHqCommercialRequest.ts`
UpdateHqCommercialRequest {
  title?: string | null;     // null = 미변경, non-null 이면 1~200
  isActive?: boolean | null; // null = 미변경
}

200 + HqCommercialDetailResponse(부분 갱신 후 전체 응답). 두 필드 모두 null = no-op(현재 상태 그대로 200). audioUrl 변경은 후속(D2 — 재업로드 흐름 별도). FE 편집 form 은 변경 없으면 [저장] disabled. hqId ≠ 주체/미존재/삭제 → 404 COMMERCIAL_SONG_NOT_FOUND(존재 은닉, BE D3).

삭제는 deleteHqCommercial(204 soft-delete, body 없음, deleted_at 채움). 404 COMMERCIAL_SONG_NOT_FOUND (affected=0 — 이미 삭제·미존재·타 본사 모두 은닉). FE 는 상세 4번째 섹션의 2-step 인라인 confirm strip 으로 호출(404=이미 삭제됨 흡수 → 목록 복귀).

HQ Mode CS 티켓 DTOs (#086 — apps/space /admin/support)

본사(HQ_MANAGER) 가 운영사에 문의·요청·이슈를 보고하는 CS 채널. 운영자 ticket 백본(#027) 의 BE service 를 재사용하되 본사 view 는 별도 endpoint(/api/v1/hq/tickets/*)·DTO 로 분리(운영자 assignee 등 과노출 회피, BE D5 — 같은 SPEC #086). enum 도메인은 운영자 ticket 과 값이 같지만 generated 는 응답별로 enum 을 분리 emit (HqTicketListItemStatus·HqTicketDetailResponseStatus· CreateHqTicketRequestPriority 등). 운영자 ticket DTO 의 INTERNAL 메모는 본사 응답에 포함되지 않는다 (BE D5 — 운영자 내부 채널 보호).

HqTicketListItem (#086 — GET /api/v1/hq/tickets item)

// generated `schemas/hqTicketListItem.ts`
HqTicketListItem {
  id: string;
  title: string;
  status: HqTicketListItemStatus;       // OPEN | IN_PROGRESS | RESOLVED | CLOSED
  priority: HqTicketListItemPriority;   // URGENT | HIGH | NORMAL | LOW
  commentCount: number;
  createdAt: string;                    // ISO-8601
  updatedAt: string;                    // ISO-8601
}

본사 view 는 hqName·assigneeEmail 을 노출하지 않는다(자기 본사만 보이므로 hqName 노이즈, assignee 는 운영자 내부 정보). FE 행은 제목·상태 배지·우선순위 배지·댓글 수·작성 시각 KST(createdAt).

HqTicketListResponse (#086 — 페이지네이션 응답)

// generated `schemas/hqTicketListResponse.ts`
HqTicketListResponse {
  items: HqTicketListItem[];
  page: number;   // 0-base
  size: number;
  total: number;
}

정렬은 created_at DESC, id ASC 서버 고정(결정적, BE D7). q(제목 부분일치, ≤100)·status· priority 필터. WHERE ticket.hq_id = :hqId 강제(타 본사 격리).

ListHqTicketsParams (#086 — query · generated ListHqTicketsStatus/ListHqTicketsPriority, operationId listHqTickets)

// generated `schemas/listHqTicketsParams.ts`
ListHqTicketsParams {
  q?: string;                       // @maxLength 100 — 제목 부분일치(대소문자 무시)
  status?: ListHqTicketsStatus;     // OPEN | IN_PROGRESS | RESOLVED | CLOSED
  priority?: ListHqTicketsPriority; // URGENT | HIGH | NORMAL | LOW
  page?: number;                    // 0-base
  size?: number;                    // 1..100 clamp
}

apps/space HqTicketListClient 가 draft↔applied 분리(공용 ListToolbar 패턴) — 적용 시 page=0 리셋. FE 는 generated enum 값을 화이트리스트로 sanitize(공유 URL·미동기 enum 폴백).

HqTicketDetailResponse (#086 — GET /api/v1/hq/tickets/{id} 200 · POST /api/v1/hq/tickets 201)

// generated `schemas/hqTicketDetailResponse.ts`
HqTicketDetailResponse {
  id: string;
  title: string;
  body: string;                                  // 본문(작성 시 입력, 최대 5000자)
  status: HqTicketDetailResponseStatus;          // 동일 enum
  priority: HqTicketDetailResponsePriority;
  submitterEmail: string;                        // 본사 매니저 이메일 스냅샷(BE D6)
  createdAt: string;
  updatedAt: string;
  comments: HqTicketCommentItem[];               // 생성순 오름차순(시간순)
}

타 본사 ticket id 도 404 TICKET_NOT_FOUND(존재 은닉, BE D3). FE 4 섹션(헤더·메타·본문·댓글). 본문은 pre-wrap(공백·줄바꿈 보존). 작성 응답(201)도 같은 DTO — 작성 직후 comments=[].

HqTicketCommentItem (#086 — 댓글 스레드 item · POST .../comments 201)

// generated `schemas/hqTicketCommentItem.ts`
HqTicketCommentItem {
  id: string;
  body: string;                                  // @maxLength 5000
  authorRole: HqTicketCommentItemAuthorRole;     // HQ_MANAGER | OPERATOR
  authorEmail: string;                           // 작성자 이메일 스냅샷
  createdAt: string;                             // ISO-8601
}

운영자 TicketCommentItem.kind=INTERNAL 메모는 본사 응답에 포함되지 않음(BE D5 — 별도 DTO). authorRole 시각 매핑: HQ_MANAGER=“내 매장”(info 톤·본사 매니저), OPERATOR=“운영사”(success 톤· 운영사 응답의 긍정 신호).

CreateHqTicketRequest (#086 — POST /api/v1/hq/tickets)

// generated `schemas/createHqTicketRequest.ts`
CreateHqTicketRequest {
  title: string;                                 // @maxLength 200 · NotBlank
  body: string;                                  // @maxLength 5000 · NotBlank
  priority?: CreateHqTicketRequestPriority;      // @nullable — 누락 시 NORMAL default (BE D1)
}

hqId·submitterAccountId 는 토큰 주체 도출(요청 본문에 직접 지정 불가 — 타 본사 격리, BE D6). FE apps/space /admin/support/new 폼은 priority 를 LOW/NORMAL/HIGH 3종만 노출(URGENT 는 운영자 판단, F 후속) — backend enum 자체는 4종이라 후속 도입 시 옵션만 1줄 추가하면 된다.

AddHqTicketCommentRequest (#086 — POST /api/v1/hq/tickets/{id}/comments)

// generated `schemas/addHqTicketCommentRequest.ts`
AddHqTicketCommentRequest {
  body: string;                                  // @maxLength 5000 · NotBlank
}

응답 201 은 HqTicketCommentItem 1건. FE 는 성공 후 getGetHqTicketDetailQueryKey(id) invalidate → 상세 refetch 로 새 댓글이 스레드 끝에 자동 추가됨(생성순 보장).

HqTicketStatusChangeRequest / Response (#108 — PATCH /api/v1/hq/tickets/{id}/status)

// generated `schemas/hqTicketStatusChangeRequest.ts` · `hqTicketStatusChangeResponse.ts`
HqTicketStatusChangeRequest {
  status: HqTicketStatusChangeRequestStatus;     // OPEN | IN_PROGRESS | RESOLVED | CLOSED
}
HqTicketStatusChangeResponse {
  status: HqTicketStatusChangeResponseStatus;    // 변경 후 status
}

본사 허용 전이는 RESOLVED→CLOSED·RESOLVED→IN_PROGRESS·CLOSED→IN_PROGRESS 만(운영자 전이표 부분집합). 비허용 전이 → 409 TICKET_INVALID_STATUS_TRANSITION. FE 는 성공 후 getGetHqTicketDetailQueryKey(id) invalidate.

HqTicketPriorityChangeRequest / Response (#108 — PATCH /api/v1/hq/tickets/{id}/priority)

// generated `schemas/hqTicketPriorityChangeRequest.ts` · `hqTicketPriorityChangeResponse.ts`
HqTicketPriorityChangeRequest {
  priority: HqTicketPriorityChangeRequestPriority; // URGENT | HIGH | NORMAL | LOW
}
HqTicketPriorityChangeResponse {
  priority: HqTicketPriorityChangeResponsePriority; // 변경 후 priority
}

본사는 LOW/NORMAL/HIGH 만 설정 가능 — URGENT 요청 시 400 HQ_TICKET_PRIORITY_FORBIDDEN (req enum 은 URGENT 포함이나 BE 가 단일 방어선, FE 세그먼트는 3단계만 노출). 동일값 멱등 200.

HQ CS Ticket enums (#086 · #108)

enum비고
HqTicketListItemStatus (= detail)OPEN · IN_PROGRESS · RESOLVED · CLOSED운영자 TicketStatus 와 값 일치 — generated 만 분리 emit
HqTicketListItemPriority (= detail · CreateHqTicketRequestPriority)URGENT · HIGH · NORMAL · LOW운영자 TicketPriority 와 값 일치
HqTicketCommentItemAuthorRoleHQ_MANAGER · OPERATOR본사 view 전용 — INTERNAL 없음(BE D5)
ListHqTicketsStatus/Priority동값query 파라미터 enum (orval 분리 emit)
HqTicketStatusChangeRequestStatus/ResponseStatus (#108)OPEN · IN_PROGRESS · RESOLVED · CLOSED전 enum req/resp 분리 emit. 정책 허용 전이는 부분집합(BE 검증)
HqTicketPriorityChangeRequestPriority/ResponsePriority (#108)URGENT · HIGH · NORMAL · LOWreq 는 전값 포함하나 URGENT 는 400 거부(BE 단일 방어선). FE 는 3단계만

Store Mode CS 티켓 DTOs (#112 — apps/space /store/support)

점장(STORE_MANAGER) self-service CS 채널. 본사 CS(#086)와 동일하게 운영자 ticket 백본을 재사용하되 store 격리 service + view DTO 분리로 과노출을 차단한다. 본사와 달리 우선순위는 view 에서 제외 (점장 미노출·서버 NORMAL 고정), category 신규 노출(작성 시 필수), 댓글은 kind=REPLY 만(INTERNAL 메모 비노출). orval 은 응답별 enum 분리 emit.

StoreTicketListItem (#112 — GET /api/v1/store/tickets item)

// generated `schemas/storeTicketListItem.ts`
StoreTicketListItem {
  id: string;
  title: string;
  status: StoreTicketListItemStatus;       // OPEN | IN_PROGRESS | RESOLVED | CLOSED
  category?: StoreTicketListItemCategory;   // PLAYBACK | BROADCAST | BILLING | ACCOUNT | OTHER (nullable — 공용 테이블)
  commentCount: number;
  createdAt: string;                        // ISO-8601
  updatedAt: string;
}

우선순위 필드 없음(본사 item 과 차이). category? 는 공용 ticket 테이블이 nullable 이라 옵셔널 — 점장 작성 행은 항상 채워지지만 레거시/타 채널 행은 미설정일 수 있어 FE 는 ”—” 폴백.

StoreTicketListResponse / ListStoreTicketsParams (#112)

StoreTicketListResponse { items: StoreTicketListItem[]; page; size; total; }
 
// generated `schemas/listStoreTicketsParams.ts`, operationId `listStoreTickets`
ListStoreTicketsParams {
  q?: string;                        // 제목 부분검색 ≤100
  status?: ListStoreTicketsStatus;   // OPEN | IN_PROGRESS | RESOLVED | CLOSED
  category?: ListStoreTicketsCategory; // PLAYBACK | BROADCAST | BILLING | ACCOUNT | OTHER
  page?: number;                     // 0-base
  size?: number;                     // 1..100 clamp
}

apps/space StoreTicketListClient 가 draft↔applied 분리(공용 ListToolbar) — 적용 시 page=0.

StoreTicketDetailResponse (#112 — GET /api/v1/store/tickets/{id} 200 · POST /api/v1/store/tickets 201 · PATCH .../{id}/close 200)

// generated `schemas/storeTicketDetailResponse.ts`
StoreTicketDetailResponse {
  id: string;
  title: string;
  body: string;
  status: StoreTicketDetailResponseStatus;
  category?: StoreTicketDetailResponseCategory;
  createdAt: string;
  updatedAt: string;
  comments: StoreTicketCommentItem[];   // 생성순 오름차순, REPLY 만
}

본사 detail 의 priority·submitterEmail 없음(점장 view 미노출). 우선순위는 서버 NORMAL 고정이라 점장에게 의미 없고, 작성자는 본인 매장이라 이메일 노출 불필요.

SPEC #121 확인 종료(PATCH .../{id}/close)도 이 DTO 를 read-back 으로 재사용한다(별도 응답 DTO 없음 — 종료 직후 status=CLOSED 로 갱신된 상세를 200 으로 반환). 점장은 RESOLVED→CLOSED 1종만 — 비-RESOLVED → 409 TICKET_INVALID_STATUS_TRANSITION, 미존재·타 매장 → 404 TICKET_NOT_FOUND.

StoreTicketCommentItem (#112 — 댓글 스레드 item · POST .../comments 201)

// generated `schemas/storeTicketCommentItem.ts`
StoreTicketCommentItem {
  id: string;
  body: string;
  authorRole: StoreTicketCommentItemAuthorRole;   // STORE_MANAGER | OPERATOR
  createdAt: string;
}

본사 comment 의 authorEmail 없음(점장 view 단순화). INTERNAL 메모는 노출되지 않는다(BE D3).

CreateStoreTicketRequest / AddStoreTicketCommentRequest (#112)

// generated `schemas/createStoreTicketRequest.ts`
CreateStoreTicketRequest {
  title: string;                          // 1~200, NotBlank
  body: string;                           // 1~5000, NotBlank
  category: CreateStoreTicketRequestCategory;  // 필수(@NotNull) — 본사 priority? 와 달리 nullable 아님
}
 
AddStoreTicketCommentRequest { body: string; }  // 1~5000

storeId·hqId·priority·status 는 요청 본문에 없다 — 토큰 주체 도출 + 서버 고정(OPEN·NORMAL·hq_id=null). 댓글 추가 응답 201 은 StoreTicketCommentItem 1건. FE 는 성공 후 getGetStoreTicketDetailQueryKey(id) invalidate.

Store CS Ticket enums (#112)

enum비고
StoreTicketListItemStatus (= detail)OPEN · IN_PROGRESS · RESOLVED · CLOSED운영자 TicketStatus 와 값 일치 — generated 만 분리 emit
StoreTicketListItemCategory (= detail · CreateStoreTicketRequestCategory · ListStoreTicketsCategory)PLAYBACK · BROADCAST · BILLING · ACCOUNT · OTHER신규 TicketCategory — 공용 테이블 nullable, 점장 작성 시 필수
StoreTicketCommentItemAuthorRoleSTORE_MANAGER · OPERATOR점장 view 전용 — INTERNAL 없음(BE D3)
ListStoreTicketsStatus동값query 파라미터 enum (orval 분리 emit)

StoreSupportUnreadSignalResponse (#119 F5 — GET /api/v1/store/support/unread-signal)

// generated `schemas/storeSupportUnreadSignalResponse.ts`
{
  latestOperatorReplyAt?: string | null;  // 본인 매장 ticket 의 운영자 REPLY max createdAt(없으면 null)
}

operationId getStoreSupportUnreadSignal(D2 dot). 점장은 카운트 불요 — dot(boolean)만. store_id 격리. FE 는 이 값을 localStorage lastSeen(lm.support.lastSeen.store.<storeId>)과 비교해 dot 판정(F4 헬퍼 재사용). 점장 안내방송 배지는 만들지 않는다(D3 — player 가 PENDING 을 이미 자동 소비, 중복). generated 훅 useGetStoreSupportUnreadSignal. apps/space 점장 player 헤더 [고객지원] Link dot(60초 폴링, 안내방송 20초와 별개 query).

PendingAnnouncementsResponse / PendingAnnouncementItem (송출 슬라이스 — GET /api/v1/store/announcements/pending)

// generated `schemas/pendingAnnouncementsResponse.ts`·`pendingAnnouncementItem.ts`
PendingAnnouncementsResponse {
  items: PendingAnnouncementItem[];   // 본인 매장의 미재생(PENDING) 송출 — created_at ASC
  revokedDispatchIds: string[];       // (SPEC #077 확장) 본사 원격 즉시중단(revoke) 신호 — 오늘 KST 윈도우 본인 매장에서 본사가 revoke 한 dispatchId 목록. revoke 시 CANCELED 전이로 items 에선 자동 제외되므로 별도 신호로 노출. player 가 재생 중 멘트의 dispatchId 가 이 목록에 있으면 즉시 중단(best-effort, ack 안 함)·재선출 차단.
}
 
PendingAnnouncementItem {
  dispatchId: string;                 // 송출 id (ack 시 사용)
  announcementId: string;             // 안내방송 id
  title: string;                      // 안내방송 제목
  audioUrl: string;                   // 합성 오디오 재생 URL (Azure public blob)
  durationSeconds?: number | null;    // 오디오 길이(초), null 가능
  isEmergency: boolean;               // (SPEC #082) 긴급방송 여부 — 점장 즉시방송 옵션. 본 슬라이스는 노출만(player 인터럽트 F1·audit 누적 F2 후속).
}

operationId listStorePendingAnnouncements(200). 본인 매장의 PENDING 송출만(created_at ASC). apps/space /store player 가 20초 폴링해 곡 끝에 가장 오래된 것부터 1회 삽입 재생. ack 은 ackStoreAnnouncement(204·멱등, 본문 없음). revokedDispatchIds(SPEC #077 확장)는 본사 원격 즉시중단(revoke) 신호로, player 가 재생 중 멘트 즉시 중단·재선출 차단에 쓴다(ack 안 함, Store Player). DispatchStatus enum(enums).

BroadcastPreviewRequest / BroadcastPreviewResponse / BroadcastSendRequest / BroadcastSendResponse / BroadcastRecordingResponse (즉시방송 슬라이스 — /api/v1/store/broadcasts*)

점장 즉시방송(2-step 미리듣기 게이트). 입력 방식 2종 — TTS(텍스트→합성)·녹음(마이크→업로드). 어느 방식이든 draft 를 만든 뒤(미리듣기) 자기 매장에 송출한다(send 공유).

// generated `schemas/broadcastPreviewRequest.ts`·`broadcastPreviewResponse.ts`·`broadcastSendRequest.ts`·`broadcastSendResponse.ts`·`broadcastRecordingResponse.ts`·`recordStoreBroadcastParams.ts`
BroadcastPreviewRequest {                  // POST /api/v1/store/broadcasts/preview body (TTS)
  text: string;                            // 방송 텍스트 (@minLength 1 @maxLength 200)
  voice: BroadcastPreviewRequestVoice;     // TtsVoice 5종(SHEAN·WOOSUNG·CYRUS·AERAN·SEUNGA), 톤 NORMAL 고정
  isEmergency?: boolean | null;            // (SPEC #082) 긴급방송 여부. null/생략=일반 · true=긴급. FE 는 true 만 명시 전송.
}
 
BroadcastPreviewResponse {                 // TTS preview 201
  announcementId: string;                  // 생성된 draft 안내방송 id (send 시 사용)
  audioUrl: string;                        // 합성 오디오 미리듣기 URL (Azure public blob)
  durationSeconds?: number | null;         // 오디오 길이(초), Typecast 제공 시만
  isEmergency: boolean;                    // (SPEC #082) preview 단계에서 확정된 긴급 여부(null=false 정규화). send 단계에서 재확정 가능.
}
 
// POST /api/v1/store/broadcasts/recording (녹음): multipart file=Blob + query (durationSeconds, isEmergency?)
// RecordStoreBroadcastBody{ file: Blob }
// RecordStoreBroadcastParams{ durationSeconds: number; isEmergency?: boolean }  // (SPEC #082) query param — null/생략=false
BroadcastRecordingResponse {               // 녹음 업로드 201
  announcementId: string;                  // 생성된 draft 안내방송 id (send 시 사용 — preview 와 동형)
  audioUrl: string;                        // 업로드된 녹음 미리듣기 URL (Azure public blob)
  durationSeconds?: number | null;         // 녹음 길이(초), 클라 측정값을 서버가 1~30 검증
  isEmergency: boolean;                    // (SPEC #082) multipart query 의 isEmergency 를 반영(null=false). send 단계에서 재확정 가능.
}
 
BroadcastSendRequest {                     // POST /api/v1/store/broadcasts/{announcementId}/send body (SPEC #082·#083)
  isEmergency?: boolean | null;            // (SPEC #082) 송출 마지막 단계의 isEmergency 확정. null/생략 시 preview 단계 값 유지. true=긴급. FE 는 일반이면 생략, 긴급이면 true 명시 전송.
  scheduledAt?: string | null;             // (SPEC #083) ISO-8601 nullable. null/생략=즉시 송출(기존 동작·status=PENDING). non-null=예약 송출(status=SCHEDULED, scheduled_at 적재 → 본사 #078 `HqDispatchScheduler` 1분 cron 이 도래 시 PENDING 자동 전이 → 점장 player polling 자동 픽업, 코드 변경 0). 과거(`<= now`) → 400 BROADCAST_SCHEDULED_AT_PAST · 1년 초과 → 400 BROADCAST_SCHEDULED_AT_TOO_FAR. isEmergency 와 자유 조합 가능(긴급 예약 = 정당한 use case). FE 는 일반 즉시 송출이면 `{}` 빈 body, 예약이면 `{ scheduledAt }`, 긴급 예약이면 `{ isEmergency: true, scheduledAt }`.
}
 
BroadcastSendResponse {                    // POST /api/v1/store/broadcasts/{announcementId}/send 201 (공유)
  dispatchId: string;                      // 생성된 송출 id
}

operationId previewStoreBroadcast(201)·sendStoreBroadcast(201). preview 는 STORE_BROADCAST draft row 를 만들 뿐 dispatch 하지 않는다(미리듣기 게이트). send 는 그 draft 를 본인 매장에 PENDING 송출로 fan-out 한다 — 이후 점장 player 의 pending 폴링(listStorePendingAnnouncements)이 잡아 곡 끝에 삽입 재생→ack(본사 송출과 동일 고리 재사용). storeId·hqId 는 토큰 주체에서 도출. 미설정 503 TTS_TOKEN_NOT_CONFIGURED·합성 실패 502 TTS_SYNTHESIS_FAILED(둘 다 row 미생성)· send 시 만료/타 매장 draft 404. voice enum 은 endpoint 별 generated type BroadcastPreviewRequestVoice(value 는 TtsVoice 5종과 동일). 녹음 탭recordStoreBroadcast (multipart file+durationSeconds)로 같은 draft 를 만들어 동일 send 로 송출한다 — 미지원 형식 400 RECORDING_UNSUPPORTED_FORMAT·빈/10MB 초과/길이 범위 밖 400 RECORDING_INVALID_FIELD. apps/space /store/broadcast/now TTS·녹음 탭이 미리듣기 게이트를 공유(송출 버튼은 미리듣기/업로드 성공 후 활성, 입력 변경·탭 전환 시 재비활성).

BroadcastTemplateResponse / BroadcastTemplateListResponse / CreateBroadcastTemplateRequest / UpdateBroadcastTemplateRequest (자주쓰는방송 슬라이스 — /api/v1/store/broadcast-templates*)

점장 자주 쓰는 방송 템플릿 CRUD. 저장해둔 안내(이름·텍스트·목소리)를 TTS 탭으로 불러와(로드) 재사용한다. 합성/송출은 하지 않고 즉시방송 입력을 채우는 로드까지만 담당한다(미리듣기·송출은 TTS 게이트가 맡음).

// generated `schemas/broadcastTemplateResponse.ts`·`broadcastTemplateListResponse.ts`·`createBroadcastTemplateRequest.ts`·`updateBroadcastTemplateRequest.ts`
BroadcastTemplateResponse {                // 본인 매장 활성 템플릿 행 (updated_at DESC)
  id: string;                              // 템플릿 id
  name: string;                            // 표시 이름
  text: string;                            // 방송할 텍스트
  voice: BroadcastTemplateResponseVoice;   // TtsVoice 5종(SHEAN·WOOSUNG·CYRUS·AERAN·SEUNGA)
  createdAt: string;                       // 생성 시각 (ISO-8601)
  updatedAt: string;                       // 수정 시각 (ISO-8601)
}
 
BroadcastTemplateListResponse {            // GET /api/v1/store/broadcast-templates 200
  items: BroadcastTemplateResponse[];      // updated_at DESC
  total: number;                           // 활성 템플릿 건수 (매장당 최대 20)
}
 
CreateBroadcastTemplateRequest {           // POST body (201)
  name: string;                            // @minLength 1 @maxLength 60
  text: string;                            // @minLength 1 @maxLength 200 (즉시방송 text 와 동일 제약)
  voice: CreateBroadcastTemplateRequestVoice;
}
 
UpdateBroadcastTemplateRequest {           // PUT /{id} body (200) — Create 와 동형
  name: string;                            // @minLength 1 @maxLength 60
  text: string;                            // @minLength 1 @maxLength 200
  voice: UpdateBroadcastTemplateRequestVoice;
}

operationId listBroadcastTemplates(200)·createBroadcastTemplate(201)·updateBroadcastTemplate(200)· deleteBroadcastTemplate(204, 소프트삭제). 목록은 페이지네이션 없이 전량(매장당 최대 20, updated_at DESC). 생성 시 20개 도달 409 BROADCAST_TEMPLATE_LIMIT_EXCEEDED, 수정/삭제 시 본인 매장 활성 템플릿이 아니면 404 BROADCAST_TEMPLATE_NOT_FOUND(소유 은닉). storeId 는 토큰 주체에서 도출. voice enum 은 endpoint 별 generated type(value 는 TtsVoice 5종과 동일 — broadcast-voice-meta.ts 라벨 재사용). apps/space /store/broadcast/now 자주 쓰는 방송 탭(목록·인라인 저장/편집·2-step 삭제·행 탭=로드).

StoreScheduledBroadcastItem / StoreScheduledBroadcastListResponse (SPEC #091 · #102 페이지네이션 — /api/v1/store/scheduled-broadcasts)

점장 본인 매장의 예약 송출(SCHEDULED dispatch row) 목록 조회 응답. 본사 #078 디스패처가 도래 시 PENDING 으로 전이하기 직전 row 만 노출되며(즉시/PENDING·종착/PLAYED·CANCELED 자동 제외), 정렬은 scheduled_at ASC (가장 가까운 예약이 위). storeId 는 토큰 주체에서 도출 → 타 매장 row 비노출. #102 마감: query page?=0·size?=20(size 1..50 clamp) + envelope {items,page,size,total}total 은 페이지 무관 본인 매장 SCHEDULED 전체 카운트.

// generated `schemas/storeScheduledBroadcastItem.ts` · `storeScheduledBroadcastListResponse.ts`
StoreScheduledBroadcastItem {              // GET /api/v1/store/scheduled-broadcasts 200 item
  dispatchId: string;                      // SCHEDULED dispatch row id (UUID)
  announcementTitle: string;               // announcement 와 JOIN — 표 첫 컬럼
  audioUrl: string;                        // announcement.audio_url 스냅샷 (Azure public blob, 미리듣기 후속)
  scheduledAt: string;                     // ISO-8601 UTC — 도래 시 디스패처가 PENDING 전이
  isEmergency: boolean;                    // (SPEC #082) 긴급 예약 여부 — 표에 "긴급" pill 분기
  createdAt: string;                       // ISO-8601 UTC — 예약 row 등록 시각
}
 
StoreScheduledBroadcastListResponse {      // GET 200
  items: StoreScheduledBroadcastItem[];    // scheduled_at ASC, 현재 페이지 size 만큼
  page: number;                            // (#102) 0-base 현재 페이지
  size: number;                            // (#102) 페이지 크기 (1..50 clamp, default 20)
  total: number;                           // (#102) 페이지 무관 본인 매장 SCHEDULED 전체 카운트
}

operationId listStoreScheduledBroadcasts(200). query page?·size?. STORE_MANAGER-only · 미인증 401 · 비활성/role 불일치 403 PRINCIPAL_SCOPE_MISMATCH. generated 훅 useListStoreScheduledBroadcasts({ page, size })· getListStoreScheduledBroadcastsQueryKey. apps/space /store/broadcast/scheduled read-only 표 + 하단 ListPagination(total > size 일 때만 노출, 1-base display). 미리듣기 audio 노출은 후속 F2.

Operator DTOs (#032 — /api/v1/admin/operators*)

운영자(OPERATOR) 계정 관리. 점장(#021·#029)과 동일 AccountStatus 라이프사이클이되 self-guard·마지막 활성 운영자 보호가 추가된다.

OperatorListResponse · OperatorListItem · ListOperatorsParams (#032 · #043)

// generated `schemas/operatorListResponse.ts` · `operatorListItem.ts` · `listOperatorsParams.ts`
 
// #043 — 검색·상태필터·페이지네이션 query 파라미터(전부 optional).
ListOperatorsParams {
  q?: string;                                         // email·name 부분일치(대소문자 무시), max 100
  status?: "ACTIVE" | "SUSPENDED" | "WITHDRAWN";       // ListOperatorsStatus — 미지정 시 전체(WITHDRAWN 포함)
  page?: number;                                       // 0-base, default 0
  size?: number;                                       // default 20, 1..100 clamp
}
 
// #043 — envelope 전환(`{items}` → `{items,page,size,total}`).
OperatorListResponse {
  items: OperatorListItem[];                           // 현재 페이지(createdAt asc, id asc)
  page: number;                                        // 0-base 페이지 번호
  size: number;                                        // 페이지 크기(1..100)
  total: number;                                       // 필터 적용 후 전체 건수
}
 
OperatorListItem {
  id: string;
  email: string;
  name?: string | null;                              // 없으면 null → FE '—'
  status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN";       // OperatorListItemStatus
  passwordMustChange: boolean;                        // 임시 비번 상태(첫 로그인 변경 강제)
  lastLoginAt?: string | null;                        // ISO, 없으면 null
  createdAt: string;                                  // ISO
  isSelf: boolean;                                    // 현재 로그인 운영자 본인 여부(FE 자기 행 액션 비활성)
}

OperatorIssueRequest · OperatorIssueResponse (#032)

OperatorIssueRequest  { email: string; name: string; tempPassword: string; } // tempPassword OpenAPI minLength 8/maxLength 100, email·name max 255 (런타임 @Email·@NotBlank)
OperatorIssueResponse { id: string; }                                          // 201, passwordMustChange=true

OperatorResetPasswordRequest · OperatorSuspendRequest · OperatorAccountResponse (#032)

OperatorResetPasswordRequest { tempPassword: string; } // OpenAPI minLength 8, maxLength 100
OperatorSuspendRequest       { reason: string; }       // OpenAPI maxLength 255 (런타임 @NotBlank)
 
OperatorAccountResponse {
  id: string;
  status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN";        // 전이 후 상태
  passwordMustChange: boolean;                          // 비번 재설정 시 true
}
  • reset-password 응답 = OperatorAccountResponse. ACTIVE 계정만(그 외 → 409 ACCOUNT_INVALID_STATUS_TRANSITION). 본인 → 403 OPERATOR_SELF_ACTION_FORBIDDEN. FE zod 가 길이(min 8/max 100)를 미러해 사전 reject.
  • suspend 본문 = OperatorSuspendRequest. 본인 → 403 OPERATOR_SELF_ACTION_FORBIDDEN · 마지막 활성 → 403 OPERATOR_LAST_ACTIVE_FORBIDDEN. FE zod 가 trim 후 min 1 로 NotBlank 미러.
  • reactivate(body 없음)·revoke(DELETE, body 없음)는 상태 전이만. 회수는 본인·마지막 활성 보호 동일. 잘못된 전이/이미 WITHDRAWN → 409 · 미존재 → 404 OPERATOR_NOT_FOUND.
  • 정지·재설정·회수는 대상 운영자의 활성 세션을 즉시 무효화한다.

Admin / Stats DTOs

AdminStatsResponse (#017 — GET /api/v1/admin/stats, OPERATOR-only · #035·#126 확장)

ops 대시보드 핵심 지표. 전부 현재 데이터로 계산 가능한 값만(매출·장애 제외 — 도메인 미존재/무료 MVP). #035 분포·추이 필드, #126 송출·audit 필드 추가(operationId·경로·인가 불변). 기존 필드(totalHqs·hqsByStatus·newHqs7d·totalStores·activeImpersonations)는 불변 유지(FE 기존 카드 회귀 없음).

{
  totalHqs: number;            // Hq count
  hqsByStatus: HqStatusCounts; // group by status
  newHqs7d: number;            // Hq createdAt ≥ now−7d
  totalStores: number;         // Store count
  activeImpersonations: number; // active(미revoke·미만료) impersonated RefreshToken 수
  // — #035 추가 —
  storesByStatus: StoreStatusCounts; // Store.status group by (합 = totalStores)
  hqsByType: HqTypeCounts;           // Hq.type group by
  accounts: AccountStatsCounts;      // OperatorAccount role×status 단일 group by(최소셋)
  tickets: TicketStatsCounts;        // Ticket.status group by + 미배정·긴급 OPEN 보조 count
  hqSignups30d: DailyCount[];        // 최근 30일 본사 가입 추이(가입 있는 날만 — FE 가 0 채움)
  // — #126 추가 —
  dispatch: DispatchStatsCounts;     // AnnouncementDispatch.status group by + 최근 7일 송출 건수
  dispatchTrend30d: DailyCount[];    // 최근 30일 송출 추이(송출 있는 날만 — FE 가 0 채움)
  storeAuditActivity7d: number;      // 최근 7일 매장 audit(store_audit_log) 활동 수
}

HqStatusCounts (#017)

{
  active: number;
  onboarding: number;
  unpaid: number;
  suspended: number;
}

StoreStatusCounts (#035)

{
  active: number;     // ACTIVE 매장 수
  suspended: number;  // SUSPENDED 매장 수
  inactive: number;   // INACTIVE 매장 수
}

HqTypeCounts (#035)

{
  franchise: number;    // FRANCHISE 본사 수
  independent: number;  // INDEPENDENT(가상 본사) 수
}

AccountStatsCounts (#035 — 최소셋)

운영자·점장·HQ매니저 계정 분포(최소셋). 점장·HQ매니저의 SUSPENDED/WITHDRAWN 은 비목표.

{
  operatorsActive: number;     // ACTIVE 운영자 수
  operatorsSuspended: number;  // SUSPENDED 운영자 수
  hqManagersActive: number;    // ACTIVE HQ매니저 수
  storeManagersActive: number; // ACTIVE 점장 수
}

TicketStatsCounts (#035)

CS 티켓 분포. 미해결 = open + inProgress(FE 합산). unassignedOpen·urgentOpen 은 OPEN 한정 보조 count.

{
  open: number;
  inProgress: number;
  resolved: number;
  closed: number;
  unassignedOpen: number; // 미배정(assignee NULL) OPEN 수
  urgentOpen: number;     // 긴급(URGENT) OPEN 수
}

DispatchStatsCounts (#126)

안내방송 송출(AnnouncementDispatch) status 분포 + 최근 7일 송출 건수. 도달률은 BE 가 보내지 않고 FE 파생(played / (pending + played)). dispatch row 수 = 본사→매장 fan-out 시도 총량.

{
  pending: number;     // PENDING(미재생) 송출 수
  played: number;      // PLAYED(재생 완료) 송출 수
  scheduled: number;   // SCHEDULED(예약 대기) 송출 수
  canceled: number;    // CANCELED(본사 취소) 송출 수
  last7dCount: number; // 최근 7일 송출 건수(전역 dispatch row 수)
}

DailyCount (#035 — hqSignups30d·#126 dispatchTrend30d 공용)

{
  date: string;   // 일자 ISO date (YYYY-MM-DD)
  count: number;  // 해당 일자 건수
}

hqSignups30d·dispatchTrend30d 는 BE 가 있는 날만 반환한다(가입 추이는 가상 본사 INDEPENDENT 제외). FE 는 오늘(KST) 기준 최근 30일 축을 만들어 빈 날 count=0 으로 채워 렌더한다(매칭은 ISO date 문자열 비교, KST 기준일 — recentKstDayKeys 유틸).

도달률·티켓 해결률은 BE 필드가 아니라 FE 파생이다(AdminStatsResponse 에 비율 필드 없음): 도달률 = dispatch.played / (dispatch.pending + dispatch.played), 티켓 해결률 = (tickets.resolved + tickets.closed) / Σtickets. 분모 0 이면 ”—”.

TtsSmokeTestResponse (#134 — POST /api/v1/admin/tts/smoke-test, OPERATOR-only)

TTS 연동 진단(부작용 없는 합성 1회) 성공 응답. 실패는 표준 ErrorResponse(503 TTS_TOKEN_NOT_CONFIGURED· 502 TTS_SYNTHESIS_FAILED).

TtsSmokeTestResponse {
  ok: boolean;              // 항상 true (성공 응답에서만)
  voice: string;           // 진단에 쓴 voice enum name (SHEAN)
  durationSeconds?: number | null; // 합성 오디오 길이(초). 미제공 시 null
  audioByteSize: number;   // 합성 오디오 bytes 크기
  elapsedMs: number;       // smoke-test 호출 소요(ms)
}

Audit DTOs

ImpersonationAuditItem (#025 — GET /api/v1/admin/audit/impersonation item)

임퍼소네이션 감사 세션 한 건. 운영자(OPERATOR)가 본사 계정으로 진입한 세션 메타.

{
  id: string;            // 세션 식별자
  operatorEmail: string; // 진입한 운영자 email (운영자 표시명은 BE v1 미제공)
  hqName: string;        // 진입 대상 본사명 (진입 시점 스냅샷)
  startedAt: string;     // ISO — exchange(실제 진입) 시각
  endedAt?: string;      // ISO — 명시적 종료(EXITED) 시각. 미종료/만료면 omit
  durationSec: number;   // 파생 — 종료 시각(없으면 now) − startedAt (초)
  status: ImpersonationAuditItemStatus; // 파생 — 아래 enum
}
  • status 파생 규칙(BE): endedAt 있으면 COMPLETED · 없고 now > expiresAtEXPIRED · 그 외 ACTIVE.
  • BE v1 미제공(시안엔 있으나 데이터 없음): 운영자 표시명(email 만 제공)·ticket·진입 사유(reason)·세션 내 액션 타임라인(actions). 액션 타임라인은 HQ 모드(Surface 11)가 이벤트를 emit해야 채워지는 후속.

ImpersonationAuditItemStatus (#025)

"ACTIVE" | "COMPLETED" | "EXPIRED"

ImpersonationAuditListResponse (#025 — 페이지네이션 응답)

{
  items: ImpersonationAuditItem[];
  page: number;   // 0-based
  size: number;
  total: number;
}

ListImpersonationAuditParams (#025 — query · generated ListImpersonationAuditStatus, operationId listImpersonationAudit)

{
  from?: string;       // ISO — startedAt ≥ from
  to?: string;         // ISO — startedAt ≤ to
  operatorId?: string;
  hqId?: string;
  status?: "ACTIVE" | "COMPLETED" | "EXPIRED";
  page?: number;       // 0-based
  size?: number;
}

OperatorAuditItem (#026 — GET /api/v1/admin/audit/actions item)

운영자(OPERATOR)의 핵심 변경 액션 한 건. 임퍼소네이션 세션 감사(#025)와는 별도 테이블 (append-only 공통 감사 로그)이며 “누가·언제·무엇을·어느 대상에” 를 추적한다.

{
  id: string;          // 감사 row 식별자
  occurredAt: string;  // ISO — 액션 발생 시각
  actorEmail: string;  // 액션을 수행한 운영자 email (스냅샷)
  action: OperatorAuditItemAction;         // 아래 enum
  targetType: OperatorAuditItemTargetType; // 아래 enum
  targetId?: string;     // 대상 식별자(UUID). 일부 액션은 omit
  targetLabel?: string;  // 대상 표시 스냅샷(hqName·storeName·약관 version 등)
  detail?: string;       // 부가 상세(HQ_SUSPENDED 정지 사유·게시 version 등)
}

OperatorAuditItemAction (#026 · 운영자 액션 5종 #032 추가)

"HQ_SUSPENDED" | "HQ_REACTIVATED" | "STORE_MANAGER_ISSUED"
  | "STORE_MANAGER_PASSWORD_RESET" | "STORE_MANAGER_SUSPENDED"
  | "STORE_MANAGER_REACTIVATED" | "STORE_MANAGER_REVOKED"
  | "OPERATOR_ISSUED" | "OPERATOR_PASSWORD_RESET" | "OPERATOR_SUSPENDED"
  | "OPERATOR_REACTIVATED" | "OPERATOR_REVOKED"
  | "TERMS_PUBLISHED" | "PRIVACY_POLICY_PUBLISHED" | "HQ_ONBOARDED"
enum한글 라벨계기 (BE service)
HQ_SUSPENDED본사 정지HqStatusService.suspend (#018/#024)
HQ_REACTIVATED본사 복구HqStatusService.reactivate
STORE_MANAGER_ISSUED점장 발급StoreManagerService.issue (#021)
STORE_MANAGER_PASSWORD_RESET점장 비밀번호 재설정StoreManagerService.resetPassword (#029)
STORE_MANAGER_SUSPENDED점장 정지StoreManagerService.suspend (#029)
STORE_MANAGER_REACTIVATED점장 복구StoreManagerService.reactivate (#029)
STORE_MANAGER_REVOKED점장 회수StoreManagerService.revoke (#029)
OPERATOR_ISSUED운영자 초대OperatorService.issue (#032)
OPERATOR_PASSWORD_RESET운영자 비밀번호 재설정OperatorService.resetPassword (#032)
OPERATOR_SUSPENDED운영자 정지OperatorService.suspend (#032)
OPERATOR_REACTIVATED운영자 복구OperatorService.reactivate (#032)
OPERATOR_REVOKED운영자 회수OperatorService.revoke (#032)
TERMS_PUBLISHED약관 게시TermsDocumentService.publish (#023)
PRIVACY_POLICY_PUBLISHED개인정보처리방침 게시PrivacyPolicyService.publish (#023)
HQ_ONBOARDED본사 온보딩HqOnboardingService.register (#011)

OperatorAuditItemTargetType (#026 · OPERATOR #032 추가)

"HQ" | "STORE" | "OPERATOR" | "TERMS" | "PRIVACY_POLICY"

한글 라벨: 본사 · 매장 · 운영자 · 약관 · 개인정보처리방침.

OperatorAuditListResponse (#026 — 페이지네이션 응답)

{
  items: OperatorAuditItem[];
  page: number;   // 0-based
  size: number;
  total: number;
}

ListOperatorActionAuditParams (#026 — query · generated ListOperatorActionAuditAction/ListOperatorActionAuditTargetType, operationId listOperatorActionAudit)

{
  from?: string;            // ISO — occurredAt ≥ from
  to?: string;              // ISO — occurredAt ≤ to
  action?: OperatorAuditItemAction;
  actorOperatorId?: string;
  targetType?: OperatorAuditItemTargetType;
  q?: string;               // #034 — targetLabel+detail 부분일치(ILIKE, 대소문자 무시), max 100
  page?: number;            // 0-based
  size?: number;
}

OperatorHqAuditItem (#081 — GET /api/v1/admin/audit/hq item · #068 F4 마감)

본사(HQ_MANAGER 또는 임퍼소네이션 운영자)가 수행한 audit 행을 운영사 통합 view 로 노출한 한 건. 본사 view HqAuditItem(#067) 미러 + hqName 컬럼 추가(어느 본사의 audit 인지 식별). 본사 view 와의 차이: WHERE hq_id 가드 없음(OPERATOR-only — 전체 본사 합집합). append-only.

{
  id: string;            // audit row 식별자
  hqId: string;          // 본사 id (audit 행이 속한 본사)
  hqName: string;        // 본사명 (hq JOIN 결과 — 현재값 스냅샷)
  occurredAt: string;    // ISO — 액션 발생 시각
  actorEmail: string;    // 본사 매니저 이메일 (스냅샷)
  actorRole: OperatorHqAuditItemActorRole;          // "HQ_MANAGER" | "OPERATOR_IMPERSONATING"
  impersonatedByEmail?: string | null;              // 위장 컨텍스트에서만, 그 외 null
  action: OperatorHqAuditItemAction;                // 아래 enum (5종)
  targetType: OperatorHqAuditItemTargetType;        // 아래 enum
  targetId?: string | null;       // 대상 식별자(없으면 null)
  targetLabel?: string | null;    // 대상 라벨 스냅샷(없으면 null)
  detail?: string | null;         // 액션 상세(없으면 null)
}

OperatorHqAuditItemAction (#081 — 본사 audit 5종, HqAuditItemAction #067/#068/#077 미러)

"HQ_ANNOUNCEMENT_DISPATCHED" | "HQ_ANNOUNCEMENT_CREATED"
  | "HQ_ANNOUNCEMENT_UPDATED" | "HQ_ANNOUNCEMENT_DELETED"
  | "HQ_ANNOUNCEMENT_DISPATCH_CANCELED"

한글 라벨: 송출 · 생성 · 수정 · 삭제 · 송출 취소 (운영사 측 매핑 — 본사 매핑과 동일).

OperatorHqAuditItemTargetType (#081)

"TTS_ANNOUNCEMENT"

한글 라벨: 안내방송. 1종(SPEC #067), 후속 확장 시 generated enum 자동 노출.

OperatorHqAuditItemActorRole (#081 — HqAuditItemActorRole 미러)

"HQ_MANAGER" | "OPERATOR_IMPERSONATING"

한글 라벨: 본사 매니저 · 운영자 위장.

OperatorHqAuditListResponse (#081 — 페이지네이션 응답)

{
  items: OperatorHqAuditItem[];
  page: number;   // 0-based
  size: number;
  total: number;
}

ListOperatorHqAuditParams (#081 — query · generated ListOperatorHqAuditAction/ListOperatorHqAuditTargetType, operationId listOperatorHqAudit)

{
  from?: string;          // ISO — occurredAt ≥ from
  to?: string;            // ISO — occurredAt ≤ to
  hqId?: string;          // 본사 id 필터(null=전체 본사 통합)
  actorAccountId?: string;
  action?: OperatorHqAuditItemAction;
  targetType?: OperatorHqAuditItemTargetType;
  q?: string;             // targetLabel+detail 부분일치(ILIKE), max 100
  page?: number;          // 0-based
  size?: number;
}

StoreAuditListItem (#115 — GET /api/v1/admin/audit/store item · #114 D5 reader 마감)

운영자 통합 매장 audit 행(모든 매장 합집합). hq audit OperatorHqAuditItem(#081) 1:1 미러 — 테넌트 스코프 키만 hqId/hqNamestoreId/storeName, 액션·대상 enum 이 점장 도메인용으로 바뀐다.

{
  id: string;
  storeId: string;                              // store JOIN 키(audit 행이 속한 매장)
  storeName: string;                            // 매장명(현재값, store JOIN 결과)
  occurredAt: string;                           // ISO-8601
  actorEmail: string;                           // 행위자 점장 이메일(스냅샷)
  actorRole: StoreAuditListItemActorRole;       // "STORE_MANAGER" | "OPERATOR_IMPERSONATING"
  impersonatedByEmail?: string | null;          // 위장 컨텍스트에서만(그 외 null)
  action: StoreAuditListItemAction;             // 아래 enum (7종)
  targetType: StoreAuditListItemTargetType;     // 아래 enum
  targetId?: string | null;
  targetLabel?: string | null;
  detail?: string | null;
}

StoreAuditListItemAction (#115 — 점장 액션 7종, StoreAuditAction #114 미러)

"STORE_TICKET_CREATED"            // 티켓 생성
"STORE_TICKET_COMMENT_ADDED"      // 티켓 댓글
"STORE_TICKET_CLOSED"             // 티켓 확인 종료 (#121)
"STORE_PROFILE_UPDATED"           // 프로필 수정
"STORE_PASSWORD_CHANGED"          // 비밀번호 변경
"STORE_DISPATCH_CANCELED"         // 송출 취소
"STORE_ACTIVE_PLAYLIST_CHANGED"   // 활성 PL 변경 (#129 — 선택·해제)

FE 톤: 티켓 생성/댓글·프로필 수정·활성 PL 변경 = info(정상 활동), 티켓 종료 = success, 비밀번호 변경·송출 취소 = warn(보안 민감·부정 종착). 색만으로 구분하지 않고 한글 라벨 병기.

StoreAuditListItemTargetType (#115 · #129)

"DISPATCH"          // 송출
"STORE_MANAGER"     // 점장 계정
"SUPPORT_TICKET"    // CS 티켓
"PLAYLIST"          // 플레이리스트 (#129 — 활성 PL 변경 대상)

StoreAuditListItemActorRole (#115 — StoreAuditActorRole 미러)

"STORE_MANAGER"           // 점장 본인 직접 액션
"OPERATOR_IMPERSONATING"  // 운영자가 점장 모드로 위장

StoreAuditListResponse (#115 — 페이지네이션 응답)

{
  items: StoreAuditListItem[];
  page: number;   // 0-based
  size: number;
  total: number;
}

ListStoreAuditParams (#115 — query · generated ListStoreAuditAction, operationId listStoreAudit)

ExportStoreAuditParams(operationId exportStoreAudit)는 동일하되 page/size 만 제외.

{
  from?: string;          // ISO — occurredAt ≥ from
  to?: string;            // ISO — occurredAt ≤ to
  storeId?: string;       // 매장 id 필터(null=전체 매장 통합)
  actorId?: string;       // 행위자(점장/임퍼소네이션 운영자) 계정 id
  action?: ListStoreAuditAction;
  q?: string;             // targetLabel+detail 부분일치(ILIKE), max 100
  page?: number;          // 0-based
  size?: number;
}

Ticket DTOs (#027 — CS 티켓 v1, #113 — store 식별)

#113: 운영자 목록·상세에 storeName·category(nullable) 가 추가되고, 목록 query 에 category· storeId 필터가 추가됐다(read-side LEFT JOIN store + DTO 필드 — 마이그레이션 0). category enum (TicketListItemCategory·TicketDetailCategory·ListTicketsCategory)은 점장 StoreTicketListItemCategory 와 값 동일(PLAYBACK·BROADCAST·BILLING·ACCOUNT·OTHER) — generated 만 응답별로 분리 emit. FE 는 점장 support 와 동일 한글 라벨(apps/admin ticket-meta.ts CATEGORY_META)로 표기한다.

TicketListItem (GET /api/v1/admin/tickets item)

{
  id: string;
  title: string;
  status: TicketStatus;       // OPEN | IN_PROGRESS | RESOLVED | CLOSED
  priority: TicketPriority;   // URGENT | HIGH | NORMAL | LOW
  category?: TicketListItemCategory;  // #113 nullable — PLAYBACK|BROADCAST|BILLING|ACCOUNT|OTHER. legacy/hq 티켓은 null → FE "—"
  hqName?: string | null;     // #028 nullable — 본사 미연계 시 null. FE "—"/미연계 표시
  storeName?: string | null;  // #113 nullable — store ticket 만 값(LEFT JOIN store). FE 출처(매장명) 표기
  assigneeEmail?: string | null;  // #028 nullable — 미배정 시 null. FE "미배정" 표시
  commentCount: number;
  createdAt: string;          // ISO
  updatedAt: string;          // ISO — 목록 정렬 기준(desc)
}

TicketListResponse (페이지네이션 응답)

{
  items: TicketListItem[];
  page: number;   // 0-based
  size: number;
  total: number;
}

ListTicketsParams (query — generated ListTicketsStatus/ListTicketsPriority/ListTicketsCategory, operationId listTickets)

{
  status?: TicketStatus;
  priority?: TicketPriority;
  category?: ListTicketsCategory;  // #113 — PLAYBACK|BROADCAST|BILLING|ACCOUNT|OTHER (분류별 트리아지)
  assigneeOperatorId?: string;
  hqId?: string;
  storeId?: string;         // #113 — UUID, 점장 store ticket 트리아지(FE 는 보조: URL 진입만)
  page?: number;            // 0-based
  size?: number;
}

TicketDetail (GET /api/v1/admin/tickets/{id})

{
  id: string;
  title: string;
  body: string;
  status: TicketStatus;
  priority: TicketPriority;
  category?: TicketDetailCategory; // #113 nullable — PLAYBACK|BROADCAST|BILLING|ACCOUNT|OTHER. FE 헤더 분류 배지(null 이면 생략)
  hqId?: string | null;            // #028 nullable
  hqName?: string | null;          // #028 nullable
  storeId?: string | null;         // #028 nullable
  storeName?: string | null;       // #113 nullable — store ticket 만 값. FE 헤더 출처(매장: {storeName})
  assigneeOperatorId?: string | null;  // #028 nullable — 미배정 시 null
  assigneeEmail?: string | null;   // #028 nullable
  createdByOperatorId: string;
  createdAt: string;
  updatedAt: string;
  comments: TicketCommentItem[];   // 채팅형 스레드
}

TicketCommentItem

{
  id: string;
  authorOperatorId: string;
  kind: CommentKind;   // REPLY(고객 응답) | INTERNAL(내부 메모)
  body: string;
  createdAt: string;
}

TicketCreateRequest / TicketCreateResponse

// 요청
{ title: string; body: string; priority: TicketPriority; hqId?: string; storeId?: string }
// 응답
{ id: string }

TicketCommentCreateRequest / TicketCommentCreateResponse

// 요청
{ kind: CommentKind; body: string }
// 응답
{ id: string }

TicketStatusChangeRequest / TicketStatusChangeResponse

// 요청 — 잘못된 전이는 409 TICKET_INVALID_STATUS_TRANSITION
{ status: TicketStatus }
// 응답
{ status: TicketStatus }

TicketPriorityChangeRequest / TicketPriorityChangeResponse (#046)

// 요청 — 상태머신 없어 모든 값으로 자유 전이(전이 제약·409 없음). 동일값 멱등 200. 미존재 시 404 TICKET_NOT_FOUND.
{ priority: TicketPriority }
// 응답
{ priority: TicketPriority }

changeTicketPriority(PATCH /api/v1/admin/tickets/{id}/priority). FE 훅 useChangeTicketPriority, 요청 enum TicketPriorityChangeRequestPriority(값은 TicketPriority 와 동일). 변경 이력 미기록(상태/담당자 변경과 동일 관례).

TicketAssignRequest / TicketAssignResponse

// 요청 — assigneeOperatorId 생략(omit, `{}`) = 담당자 해제.
// #028 부터 스키마가 nullable(`assigneeOperatorId?: string | null`). FE 는 해제 시 omit 으로 보낸다(관례 유지).
{ assigneeOperatorId?: string | null }
// 응답
{ assigneeOperatorId?: string | null }

Ticket enums (#027)

enum
TicketStatus (= ListStatus)OPEN · IN_PROGRESS · RESOLVED · CLOSED
TicketPriority (= ListPriority)URGENT · HIGH · NORMAL · LOW
CommentKindREPLY · INTERNAL

generated 는 응답/요청별로 동일값 enum 을 분리 emit(TicketDetailStatus·TicketListItemStatus· TicketStatusChangeRequestStatus 등)하지만 값은 모두 동일하다. FE 는 ticket-meta.ts 에서 표시용 단일 메타(STATUS_META·PRIORITY_META)로 통합한다.

Music DTOs (#041/#042 — /api/v1/admin/music*)

음원 파일 업로드 v1(#041) + 카탈로그 조회·소프트삭제(#042). 업로드/교체 요청은 multipart/form-data — 파일 파트(file)와 메타(query) 가 분리된다. generated 는 파일 파트를 *Body({ file: Blob }), 메타를 *Params(query) 로 emit 한다. 조회/삭제는 일반 JSON — 목록은 envelope MusicListResponse, 상세는 MusicResponse 재사용, 삭제는 204 No Content(바디 없음).

MusicResponse (uploadMusic 201 · replaceMusicFile 200 · getMusic 200)

상세 응답. #042 getMusic 도 이 DTO 를 재사용한다(재생 url 인 audioUrl 포함 — 목록 항목과의 차이).

{
  id: string;              // 음원 id (= blob 파일명 stem). 앱 선생성 시간순 UUID = PK (A안)
  title: string;           // 곡 제목 (TIT2 재기록 값)
  audioUrl: string;        // 저장된 음원 url (어댑터별 규약 문자열 — Local / Azure Blob). 상세 전용
  durationSeconds: number; // 곡 길이(초). 클라이언트가 추출해 전달 (DB 컬럼만, ID3 미기록)
  musicSource: "AI" | "TRUST"; // 음원 타입. 업로드 시 지정·불변 (#059)
  createdAt: string;       // ISO-8601
  updatedAt: string;       // ISO-8601
}

MusicListResponse (listMusic 200)

목록 envelope. 감사(#034) 페이지네이션 패턴 재사용 — { items, page, size, total }. total활성(deleted_at IS NULL) 음원 전체 건수.

{
  items: MusicListItem[];  // 현재 페이지 음원 행 (정렬 created_at DESC, id DESC 고정)
  page: number;            // 0-base 페이지 번호
  size: number;            // 페이지 크기 (1..100 으로 clamp)
  total: number;           // 활성 음원 전체 건수
}

MusicListItem (목록 항목)

목록 행 요약. audioUrl 제외 — 목록은 브라우징용이고 재생 url 은 상세(getMusicMusicResponse) 에서만 노출한다(불필요한 url 누출·payload 절감).

{
  id: string;              // 음원 id
  title: string;           // 곡 제목
  durationSeconds: number; // 곡 길이(초)
  musicSource: "AI" | "TRUST"; // 음원 타입 (#059). 행 배지·타입 필터에 사용
  createdAt: string;       // ISO-8601
  updatedAt: string;       // ISO-8601
  // audioUrl 없음 — 상세에서만 제공
}

ListMusicParams (GET /api/v1/admin/music query)

type ListMusicParams = {
  q?: string;     // 곡 제목 부분검색(대소문자 무시, ILIKE). 0..100자
  musicSource?: "AI" | "TRUST"; // 음원 타입 필터 (#059). 미지정 시 전체 타입
  page?: number;  // 페이지 번호 (0-base). 기본 0
  size?: number;  // 페이지 크기. 기본 20, 1..100 으로 clamp
};

목록은 활성만 노출. soft-deleted·미존재 단건 조회/삭제는 모두 404 MUSIC_NOT_FOUND(존재 은닉). 삭제(deleteMusic) 는 204 No Content — 응답 바디 없음, deleted_at 만 채우고 blob 유지.

UploadMusicBody / UploadMusicParams (POST /api/v1/admin/music)

// multipart 파일 파트
type UploadMusicBody = { file: Blob };       // 음원 MP3 파일 (최대 20MB)
// query params (클라이언트가 파일에서 추출)
type UploadMusicParams = {
  title: string;            // 곡 제목 — 필수. TIT2 재기록 값
  durationSeconds: number;  // 곡 길이(초) — 필수
  musicSource: "AI" | "TRUST"; // 음원 타입 — 필수·불변 (#059)
};

title 누락·빈 파일·20MB 초과·musicSource 누락/오값 → 400 MUSIC_INVALID_FIELD · 비-MP3/손상 ID3 → 400 METADATA_PARSE_FAILED.

ReplaceMusicFileBody / ReplaceMusicFileParams (PUT /api/v1/admin/music/:id/file)

// multipart 파일 파트
type ReplaceMusicFileBody = { file: Blob };  // 새 음원 MP3 파일
// query params
type ReplaceMusicFileParams = {
  title?: string;           // 곡 제목 — 미제공 시 기존 유지
};

같은 blob key 덮어쓰기(파일 이력 없음). 음원 미존재 → 404 MUSIC_NOT_FOUND. ID3 태그 셋·식별자 통일·재기록 파이프라인 상세는 Music Upload 참조.

Music Tag Option DTOs (#132 — /api/v1/admin/music-tag-options*)

장르·무드 태그 옵션 도메인(#132). 라이브러리·플레이리스트 분류에 쓰이는 GENRE/MOOD 옵션 CRUD. type enum 은 GENRE(장르)·MOOD(무드). 목록은 페이지네이션 없음(타입별 소규모)·sort_order ASC, value ASC 고정 정렬·비활성 포함. 삭제는 soft delete(active=false, 멱등).

MusicTagOptionResponse (createMusicTagOption 201 · updateMusicTagOption 200 · 목록 항목)

{
  id: string;                  // 옵션 id
  type: "GENRE" | "MOOD";      // 옵션 타입. 생성 후 불변
  value: string;               // 옵션 값(태그명). 1..50자
  sortOrder: number;           // 타입 내 표시 순서(오름차순). 미지정 생성 시 0
  active: boolean;             // 활성 여부. false = soft-deleted/비활성
  createdAt: string;           // ISO-8601
  updatedAt: string;           // ISO-8601
}

MusicTagOptionListResponse (listMusicTagOptions 200)

목록 envelope. 페이지네이션 없음 — { items } 만(타입별 옵션 수 소규모). 정렬 sort_order ASC → value ASC(결정적), 비활성 옵션 포함(운영자 관리 화면).

{
  items: MusicTagOptionResponse[]; // 해당 타입의 옵션 목록 (sort_order ASC, value ASC)
}

ListMusicTagOptionsParams (GET /api/v1/admin/music-tag-options query)

type ListMusicTagOptionsParams = {
  type: "GENRE" | "MOOD";  // 옵션 타입 — 필수
};

CreateMusicTagOptionRequest (POST /api/v1/admin/music-tag-options)

{
  type: "GENRE" | "MOOD";  // 옵션 타입 — 필수·불변
  value: string;           // 옵션 값. 1..50자 (@NotBlank @Size)
  sortOrder?: number | null; // 표시 순서 — 미지정/null 시 0
}

생성 시 active=true. 같은 타입 내 동일 value 중복 → 409 MUSIC_TAG_OPTION_DUPLICATE.

UpdateMusicTagOptionRequest (PATCH /api/v1/admin/music-tag-options/:id)

부분 갱신 — 세 필드 모두 null(미전송)이면 no-op(현재 상태 반환). type 은 불변(변경 불가).

{
  value?: string | null;    // 값(null/미전송 = 미변경). 1..50자
  sortOrder?: number | null; // 표시 순서(null = 미변경)
  active?: boolean | null;   // 활성 여부(null = 미변경)
}

미존재 → 404 MUSIC_TAG_OPTION_NOT_FOUND · value 변경 시 중복 → 409 MUSIC_TAG_OPTION_DUPLICATE. 삭제(deleteMusicTagOption) 는 204 No Content — active=false 로 soft delete(이미 비활성이어도 멱등). 운영사 UI 상세는 설정 18-3 참조.

Library DTOs (#053 — /api/v1/admin/libraries*)

라이브러리 도메인(#053) — 음원→라이브러리 2계층의 중간 층. 라이브러리 = 타입(AI/TRUST) 묶음의 음원 컬렉션. CRUD + 음원 할당/해제(M:N). 목록·곡 목록 envelope 는 { items, page, size, total }(감사 #034 패턴). libraryType enum 은 AI(AI 생성)·TRUST(신탁) — 색만으로 구분하지 않고 UI 에서 라벨 병기.

LibraryResponse (createLibrary 201 · getLibrary 200 · updateLibrary 200)

상세/단건 응답. musicCount(담긴 활성 음원 수) 포함 — 목록 항목과의 차이.

{
  id: string;              // 라이브러리 id
  name: string;            // 라이브러리 이름 (1..255)
  libraryType: "AI" | "TRUST"; // 타입. 생성 시 고정(이후 불변). 단일 타입 묶음(혼합 불가)
  musicCount: number;      // 담긴 활성 음원 수 (상세 전용)
  createdAt: string;       // ISO-8601
  updatedAt: string;       // ISO-8601
}

LibraryListResponse / LibraryListItem (listLibraries 200)

목록 envelope. total활성(deleted_at IS NULL) 라이브러리 전체 건수. 항목 LibraryListItem곡 수 제외(곡 수는 상세 getLibrary 에서만).

type LibraryListResponse = {
  items: LibraryListItem[];  // 현재 페이지 (정렬 created_at DESC 고정)
  page: number;              // 0-base
  size: number;              // 1..100 으로 clamp
  total: number;             // 활성 라이브러리 전체 건수
};
type LibraryListItem = {
  id: string;
  name: string;
  libraryType: "AI" | "TRUST";
  createdAt: string;
  updatedAt: string;
  // musicCount 없음 — 상세에서만 제공
};

LibraryMusicListResponse / LibraryMusicListItem (listLibraryMusic 200)

라이브러리에 담긴 음원 목록 envelope. 항목은 음원 목록(MusicListItem)과 동형이되 audioUrl 제외. 정렬은 할당 시각 DESC 고정.

type LibraryMusicListResponse = {
  items: LibraryMusicListItem[]; // 정렬 할당 시각 DESC
  page: number;
  size: number;
  total: number;                 // 라이브러리에 담긴 활성 음원 전체 건수
};
type LibraryMusicListItem = {
  id: string;              // 음원 id
  title: string;
  durationSeconds: number;
  createdAt: string;
  updatedAt: string;
};

CreateLibraryRequest / UpdateLibraryRequest / AddLibraryMusicRequest

type CreateLibraryRequest = {
  name: string;                 // 1..255
  libraryType: "AI" | "TRUST";  // 생성 시 고정
};
type UpdateLibraryRequest = {
  name: string;                 // 1..255 (libraryType 불변 — 요청에 없음)
};
type AddLibraryMusicRequest = {
  musicIds: string[];           // 0..200. 멱등 — 이미 담긴 음원은 무시
};

타입 일치 enforcement (#059): 추가하려는 음원의 musicSource 가 라이브러리 libraryType 과 다르면 전체 reject → 400 LIBRARY_TYPE_MISMATCH. 위반 musicId 는 error.fields.violatingMusicIds (콤마 구분 문자열)에 노출된다. 운영사 UI(/music → 라이브러리에 추가)는 picker 를 libraryType = 음원 musicSource 로 미리 필터해 불일치 선택을 차단하고, 그래도 발생하는 mismatch 는 사용자 메시지(“라이브러리 타입과 다른 음원은 담을 수 없습니다”)로 매핑한다.

ListLibrariesParams / ListLibraryMusicParams (query)

type ListLibrariesParams = {
  q?: string;                    // 이름 부분검색(ILIKE). 0..100자
  libraryType?: "AI" | "TRUST";  // 타입 필터
  page?: number;                 // 0-base, 기본 0
  size?: number;                 // 기본 20, 1..100 clamp
};
type ListLibraryMusicParams = {
  page?: number;                 // 0-base, 기본 0
  size?: number;                 // 기본 20, 1..100 clamp
};

라이브러리/할당 모두 활성만 노출. soft-deleted·미존재 라이브러리는 404 LIBRARY_NOT_FOUND(존재 은닉). addLibraryMusic 음원 1건이라도 미존재 → 404 MUSIC_NOT_FOUND(all-or-nothing). 삭제·제거는 204 No Content. 운영사 UI·2계층 모델 상세는 Library 참조.

Playlist DTOs (#054 — /api/v1/admin/playlists*)

플레이리스트 도메인(#054) — 음원→라이브러리→플레이리스트 2계층의 최상위 층. 플레이리스트 = 라이브러리 묶음(OPERATOR 소유, hqId 본사별). 곡 직접 안 담음 — 라이브러리 단위. CRUD + 라이브러리 담기/제거/ 순서. 목록 envelope 는 { items, page, size, total }, 담긴 라이브러리 목록은 { items, total }(순서 전체 — 페이지네이션 없음).

PlaylistStatus (#060 — 파생 상태 enum)

플레이리스트의 파생(계산) 상태. DB 컬럼·마이그레이션 없이 응답 시 집계(libraryCount·appliedStoreCount·isDefault)로 계산한다. 한 PL 은 정확히 1개 상태이며 배타적 우선순위로 판정한다.

type PlaylistStatus = "EMPTY" | "UNUSED" | "ACTIVE" | "FALLBACK";
// 판정 우선순위 (배타적): EMPTY > FALLBACK > ACTIVE > UNUSED
//   EMPTY    — libraryCount == 0 (라이브러리 미보유, 최우선)
//   FALLBACK — isDefault == true (본사 기본 PL, 점장 큐 fallback 대상)
//   ACTIVE   — appliedStoreCount >= 1 (적용 매장 있음)
//   UNUSED   — 그 외 (libraryCount>0 && !isDefault && appliedStoreCount==0)

BE 단일 파생 함수 derivePlaylistStatus(libraryCount, appliedStoreCount, isDefault)(운영사·본사 서비스 공유). FE 는 표시만 한다(서버 파생값 신뢰). FE 상태 배지 라벨/톤(운영사·본사 일치): ACTIVE → "사용 중"(초록·success) · FALLBACK → "기본"(파랑·info) · UNUSED → "미사용"(중립·muted) · EMPTY → "비어있음"(옅은 경고·warn). MISMATCH(플랜 불일치)는 플랜 게이팅 후속(#060 F1) — 이번 enum 에 없음.

PlaylistResponse (createPlaylist 201 · getPlaylist 200 · updatePlaylist 200 · setDefaultPlaylist 200)

상세/단건 응답. libraryCount(담긴 활성 라이브러리 수) 포함 — 목록 항목과의 차이.

{
  id: string;              // 플레이리스트 id
  hqId: string;            // 소유 본사 id. 생성 시 고정(이후 불변)
  name: string;            // 플레이리스트 이름 (1..255)
  isDefault: boolean;      // (#058) 본사 기본 PL 여부. 본사당 1개. setDefaultPlaylist 후 true
  libraryCount: number;    // 담긴 활성 라이브러리 수 (상세 전용)
  appliedStoreCount: number; // (#060) 이 PL 을 활성으로 쓰는 (소유 본사) 매장 수
  status: PlaylistStatus;  // (#060) 파생 상태 EMPTY|UNUSED|ACTIVE|FALLBACK
  createdAt: string;       // ISO-8601
  updatedAt: string;       // ISO-8601
}

PlaylistListResponse / PlaylistListItem (listPlaylists 200)

목록 envelope. total활성(deleted_at IS NULL) 플레이리스트 전체 건수. 항목 PlaylistListItem라이브러리 수·본사명 제외(라이브러리 수는 상세 getPlaylist, 본사명은 UI 가 listHqs 로 해석). #060 부터 appliedStoreCount·status 를 포함한다.

type PlaylistListResponse = {
  items: PlaylistListItem[]; // 현재 페이지 (정렬 created_at DESC 고정)
  page: number;              // 0-base
  size: number;              // 1..100 으로 clamp
  total: number;             // 활성 플레이리스트 전체 건수
};
type PlaylistListItem = {
  id: string;
  hqId: string;              // 소유 본사 id (본사명 아님 — UI 가 해석)
  name: string;
  isDefault: boolean;        // (#058) 본사 기본 PL 여부 — 목록 "기본" 배지 + 토글 라벨 결정
  appliedStoreCount: number; // (#060) 이 PL 을 활성으로 쓰는 (소유 본사) 매장 수
  status: PlaylistStatus;    // (#060) 파생 상태 — 목록 상태 배지
  createdAt: string;
  updatedAt: string;
  // libraryCount 없음 — 상세에서만 제공
};

운영사 목록(/playlists)·상세(/playlists/[id])는 상태 배지(#060)와 적용 매장 수를 노출한다. status=FALLBACK 행은 상태 배지가 “기본” 을 표현하므로 별도 isDefault “기본” 배지를 숨겨 중복을 피하고, 그 외 status 인 기본 PL(예: EMPTY)은 isDefault “기본” 배지를 함께 노출한다.

PlaylistLibraryListResponse / PlaylistLibraryListItem (listPlaylistLibraries 200)

플레이리스트에 담긴 라이브러리 목록 envelope(순서 전체 — page/size 없음). 정렬은 position ASC 고정.

type PlaylistLibraryListResponse = {
  items: PlaylistLibraryListItem[]; // position 순서대로
  total: number;                    // 담긴 활성 라이브러리 전체 건수
};
type PlaylistLibraryListItem = {
  id: string;              // 라이브러리 id
  name: string;            // 라이브러리 이름
  libraryType: string;     // 라이브러리 타입(AI/TRUST)
  createdAt: string;
  updatedAt: string;
};

CreatePlaylistRequest / UpdatePlaylistRequest / AddPlaylistLibraryRequest / ReorderPlaylistLibrariesRequest

type CreatePlaylistRequest = {
  hqId: string;                 // 소유 본사. 생성 시 고정
  name: string;                 // 1..255
};
type UpdatePlaylistRequest = {
  name: string;                 // 1..255 (hqId 불변 — 요청에 없음)
};
type AddPlaylistLibraryRequest = {
  libraryId: string;            // 멱등 — 이미 담긴 라이브러리는 무시, 끝에 append
};
type ReorderPlaylistLibrariesRequest = {
  libraryIds: string[];         // 0..500. 현재 담긴 라이브러리와 정확히 일치하는 새 순서
};

ListPlaylistsParams (query)

type ListPlaylistsParams = {
  q?: string;                    // 이름 부분검색(ILIKE). 0..100자
  hqId?: string;                 // 소유 본사 필터. 미지정 시 전체
  page?: number;                 // 0-base, 기본 0
  size?: number;                 // 기본 20, 1..100 clamp
};

플레이리스트는 활성만 노출. soft-deleted·미존재 플레이리스트는 404 PLAYLIST_NOT_FOUND(존재 은닉). createPlaylist 본사 미존재 → 404 HQ_NOT_FOUND · addPlaylistLibrary 라이브러리 미존재 → 404 LIBRARY_NOT_FOUND. 삭제·제거는 204 No Content. 운영사 UI·2계층 모델 상세는 Playlist 참조.

기본 PL 지정/해제 (#058 — setDefaultPlaylist · clearDefaultPlaylist)

요청 본문 없음(path id 만). setDefaultPlaylist(PUT)은 그 PL 을 본사 기본으로 지정하고 같은 본사의 기존 기본 PL 을 같은 트랜잭션에서 원자적 해제한다(본사당 1개 — partial unique index (hq_id) WHERE is_default AND deleted_at IS NULL) → 200 PlaylistResponse(isDefault=true). clearDefaultPlaylist (DELETE)은 기본 지정을 해제한다(204, 멱등 — 이미 비기본·미존재여도 성공). 기본 PL 을 deletePlaylist (#054)로 소프트삭제하면 partial index 가 deleted_at IS NULL 조건이라 is_default 도 자동 무효(별도 clear 불요). 점장 큐 fallback 은 아래 StorePlaybackQueueResponse.source 참조.

StorePlaybackQueueResponse.source (#058 — 점장 큐 fallback)

점장 큐(#056, GET /api/v1/store/playback-queue) 응답에 source 추가(하위호환 — 기존 reason 유지).

type PlaybackQueueSource = "ACTIVE" | "DEFAULT" | "NONE";
// ACTIVE  — store.active_playlist_id 로 적용된 매장 활성 PL 로 큐 생성
// DEFAULT — 매장 활성 PL 부재 → 그 매장 본사 기본 PL(is_default)로 fallback (store row 미변경·조회 시점)
// NONE    — 활성 PL 도 본사 기본 PL 도 없음 → 빈 큐, reason=NO_ACTIVE_PLAYLIST

fallback 은 비파괴(공유 모델 — store.active_playlist_id 를 자동 기록하지 않고 조회 시점에만 기본 PL 로 큐를 만든다). 점장 player 가 source=DEFAULT 일 때 “기본 재생목록 재생 중”을 안내할 수 있다(player UI 는 후속 — #058 F2). 큐 엔드포인트·DTO 전체는 점장 player 도착 시 문서화 예정.

Store Active Playlist (#055 — 매장 적용 층)

음악 2계층(음원→라이브러리→플레이리스트)의 매장 적용 층(StoreActivePlaylist). 운영사가 매장에 본사 플레이리스트 1개를 활성으로 적용/해제/조회한다. 매장당 단일 활성·공유 모델(PL 참조, 복사본 아님).

StoreActivePlaylistResponse (getStoreActivePlaylist 200 · setStoreActivePlaylist 200)

적용 PL 요약. 미적용 시 active=false·나머지 null. appliedAt 은 store 갱신 시각 근사.

{
  active: boolean;             // 활성 PL 적용 여부 (미적용 시 false)
  playlistId?: string | null;  // 적용된 PL id (미적용 시 null)
  name?: string | null;        // 적용된 PL 이름 (미적용 시 null)
  libraryCount?: number | null;// 담긴 활성 라이브러리 수 (미적용 시 null)
  appliedAt?: string | null;   // 적용 시각 근사(store.updatedAt, ISO-8601, 미적용 시 null)
}

SetStoreActivePlaylistRequest (setStoreActivePlaylist body)

{
  playlistId: string;          // 적용할 PL id. 매장 소속 본사(hqId)의 활성 PL 이어야 함
}

적용 대상 PL 의 본사(playlist.hqId)가 매장 본사(store.hqId)와 다르면 409 STORE_PLAYLIST_HQ_MISMATCH. PL 미존재·소프트삭제 → 404 PLAYLIST_NOT_FOUND · 매장 미존재 → 404 STORE_NOT_FOUND. 해제(clearStoreActivePlaylist)는 204 No Content·멱등. FE 는 후보 PL 을 매장 hqId 로 한정(useListPlaylists({ hqId }))해 mismatch 를 사전 차단한다. 매장 상세 UI 는 Store 상세 참조.

ErrorResponse

{
  success: false;
  error: {
    code: string;
    message: string;
    details?: Array<{ field: string; code: string; message: string }>;
  };
}

TypeScript import 패턴

import type {
  AuthResponse,
  MeResponse,
  HqOnboardingRequest,
} from "@linkmusic/api-client";
 
// 권장: 항상 package root 에서 import (generated 내부 경로 직접 import 금지)
// — `@linkmusic/api-client` 가 `export * from "./generated"` 로 재-export 한다.

수기 type 재정의 금지 — generated schema 만 사용 ([[feedback-15]]). 변경 시 pnpm sync-api.

Roadmap

  • DTO 카탈로그 자동 생성 (OpenAPI → MDX)
  • DTO 별 예제 JSON

References

  • linkmusic-frontend-space/packages/api-client/src/generated/schemas/
  • linkmusic-frontend-space/packages/api-client/src/generated/endpoints/
  • linkmusic-msa-space-was/.../api/*/dto/
  • OpenAPI: /v3/api-docs