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 1800 = 30min)
  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) — SPEC #147: 약관 동의가 발급에서 이 단계로 이전
SetupVerifyRequest    { token: string }
SetupVerifyResponse   { email: string; role: "OPERATOR"|"HQ_MANAGER"|"STORE_MANAGER"; expiresAt: string;
                        activeTermsVersion?: string|null; activePrivacyPolicyVersion?: string|null }  // null = OPERATOR·미게시(동의 단계 생략)
SetupCompleteRequest  { token: string; newPassword: string;
                        termsVersion?: string|null; privacyPolicyVersion?: string|null;  // @Size 32 · 활성판과 일치
                        signerName?: string|null }   // @Size 255 · 생략 시 계정명. → 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.

Email Templates (#146 — 운영사 미리보기·테스트 발송)

운영사 백오피스 이메일 템플릿 도구(OPERATOR-only). 4종 메타·미리보기·테스트 발송이 모두 실제 발송과 동일한 EmailTemplates 를 재사용한다(드리프트 0). EmailTemplateListItemType 이 4종(ACCOUNT_SETUP·PASSWORD_RESET·EMAIL_CHANGE_VERIFY·EMAIL_CHANGE_NOTICE)의 단일 소스.

type EmailTemplateListItemType =
  "ACCOUNT_SETUP" | "PASSWORD_RESET" | "EMAIL_CHANGE_VERIFY" | "EMAIL_CHANGE_NOTICE"
 
// 목록·메타 (listEmailTemplates → EmailTemplateListItem[])
EmailTemplateListItem {
  type: EmailTemplateListItemType
  label: string          // 한글 라벨
  subject: string        // 현재 발송 제목(실 발송과 동일)
  placeholders: string[] // 이 종류가 렌더에 쓰는 변수명
}
 
// 미리보기 (previewEmailTemplate(type))
EmailTemplatePreviewResponse {
  subject: string
  html: string  // 샘플 데이터로 렌더한 이메일-세이프 HTML 단일 문서
                // — FE 가 iframe srcDoc + .force-dark 토글로 라이트/다크
}
 
// 테스트 발송 (testSendEmailTemplate(type))
EmailTemplateTestSendRequest  { to: string }  // ≤254, email 형식
EmailTemplateTestSendResponse {
  delivered: boolean        // 실제 발송 성공. 미설정·벤더 실패 시 false
  emailConfigured: boolean  // Azure 메일 인프라 설정 여부. false → '메일 미설정' 안내
}

FE: apps/admin /settings/email-templates — 좌측 4종 목록 + 우측 미리보기 패널(iframe srcDoc + 라이트/다크 토글) + 테스트 발송 폼(기본 대상 = OPERATOR me.email). emailConfigured=false → 미설정 안내(미발송), delivered=true → 성공 토스트, 429 → 잠시 후 재시도.

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 / #147)

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

SPEC #147: consent 제거. 약관 동의는 발급이 아니라 본사 관리자 본인의 계정 설정(setup) 단계로 이전됐다(SetupCompleteRequest 참조). LegalConsentPart 스키마는 삭제됐다.

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 계정. 계정 관리(#158)의 대상 — id 가 계정 id 로 관리 endpoint path 의 managerId 다.

HqManagerResetPasswordRequest · HqManagerSuspendRequest · HqManagerAccountResponse (#158)

// generated `packages/api-client/src/generated/schemas/hqManager{ResetPasswordRequest,SuspendRequest,AccountResponse}.ts`
HqManagerResetPasswordRequest { tempPassword: string; }   // OpenAPI minLength 8, maxLength 100
HqManagerSuspendRequest       { reason: string; }         // OpenAPI maxLength 255 (backend 런타임 @NotBlank)
 
HqManagerAccountResponse {
  id: string;                                     // 본사 관리자 계정 id
  status: "ACTIVE" | "SUSPENDED" | "WITHDRAWN";   // 전이 후 계정 상태
  passwordMustChange: boolean;                    // 비번 재설정 시 true
}
  • 점장 계정 관리(#029, StoreManager*)를 본사 관리자에 미러한 라이프사이클 계약. reset-password 요청 본문 = HqManagerResetPasswordRequest, 응답 = HqManagerAccountResponse. ACTIVE 계정만 (그 외 → 409 ACCOUNT_INVALID_STATUS_TRANSITION). FE zod 가 길이 제약(min 8/max 100)을 미러해 사전 reject.
  • suspend 요청 본문 = HqManagerSuspendRequest(NotBlank 는 런타임 — FE zod trim 후 min 1 미러). reactivate/resend-setup 은 body 없음, revoke 는 DELETE(body 없음).
  • 임퍼소네이션 세션에서는 전 액션 403 IMPERSONATION_FORBIDDEN_ACTION(#148). FE HqManagerManageDialog 가 이 코드들을 인라인 메시지로 매핑. 감사 로그 액션 HQ_MANAGER_PASSWORD_RESET·HQ_MANAGER_SUSPENDED·HQ_MANAGER_REACTIVATED·HQ_MANAGER_REVOKED 신규(운영자 audit).

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 / HqAffiliatedStoreOnboardingRequest (#011 / #147)

// 독립 매장 — POST /api/v1/admin/stores/independent
StoreOnboardingRequest {
  store: {
    name: string;
    address?: string;
    managerName?: string;
    managerEmail?: string;
    managerPhone?: string;
    plan: "AI" | "TRUST";
    billingAnchorDay: number;
  };
}
 
// HQ 소속 매장 — POST /api/v1/admin/hq/{hqId}/stores (plan·billingAnchorDay 는 HQ 상속)
HqAffiliatedStoreOnboardingRequest {
  store: {
    name: string;
    address?: string;
    managerName?: string;
    managerEmail?: string;
    managerPhone?: string;
    storeType: "DIRECT" | "FRANCHISE";
  };
}

SPEC #147: 두 발급 request 모두 consent 제거. 약관 동의는 발급이 아니라 점장 본인의 계정 설정(setup) 단계로 이전됐다(SetupCompleteRequest 참조). LegalConsentPart 삭제.

StoreOnboardingResponse (#005/#011 · #184 확장)

// generated `schemas/storeOnboardingResponse.ts`
{
  storeId: string;
  // SPEC #184 D1/D2 — 매장 생성 트랜잭션에 점장 계정 생성이 편입됐다.
  managerAccountId?: string | null;   // 함께 생성된 STORE_MANAGER 계정 id. "점장 미정"이면 null
  managerAccountCreated: boolean;     // 의도적 파생 필드(= managerAccountId != null). FE 분기 가독성
  setupEmailSent?: boolean | null;    // 계정 미발급이면 null · 발송 성공 true · **발송 실패 false**
}

store.managerEmail 이 채워지면 그 값이 곧 점장 로그인 ID 이고 같은 트랜잭션에서 계정이 생성된다(D1). 미입력이면 “점장 미정” 으로 매장만 생성(D2). 임시 비밀번호는 서버 생성이며 응답에 노출하지 않으므로(D6) 계정 설정(setup) 메일이 유일한 진입 경로다 — setupEmailSent === false 면 계정은 있는데 들어갈 방법이 없는 상태이므로 FE 는 재발송(POST /api/v1/admin/accounts/{accountId}/resend-setup) 을 안내해야 한다. 이메일 중복은 409 DUPLICATE_EMAIL 이며 매장 생성도 롤백된다(D9) — FE 는 “매장은 등록됨” 류 안내를 띄우지 말고 이메일 필드 에러로 재제출을 유도한다.

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;      // 활성 PL 을 적용한 시각 (미적용 시 null, ISO-8601) — 전용 컬럼(V52)
}

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

StoreScheduleResponse / StoreScheduleEntry / ScheduleLibraryOption (#171 — GET /api/v1/store/me/schedule)

// generated `packages/api-client/src/generated/schemas/storeScheduleResponse.ts`
StoreScheduleResponse {
  playlistId?: string | null;             // 적용 대상 PL id (활성 PL 없으면 null)
  playlistName?: string | null;            // 적용 대상 PL 이름(표시용, 활성 PL 없으면 null)
  isOverride: boolean;                     // true=매장 맞춤(override) · false=본사 기본 표시
  entries: StoreScheduleEntry[];           // 시간표 구간(startMinute 오름차순, 비면 전체 셔플)
  availableLibraries: ScheduleLibraryOption[]; // 편집 팔레트(활성 PL 멤버, position 순)
}
 
StoreScheduleEntry {
  startMinute: number;         // 구간 시작(하루 중 분, KST, 30분 배수)
  endMinute: number;           // 구간 종료(반열림 [start,end), 30분 배수)
  libraryId: string;           // 재생 라이브러리 id
  libraryName?: string | null; // 표시용 이름(없으면 null — stale entry 는 null)
}
 
ScheduleLibraryOption {
  id: string;    // 라이브러리 id
  name: string;  // 표시용 이름
}
 
// generated `schemas/getStoreScheduleParams.ts`·`customizeStoreScheduleParams.ts`
// ·`setStoreScheduleParams.ts`·`deleteStoreScheduleParams.ts` — 4종 동일 shape (SPEC #178)
GetStoreScheduleParams {
  deviceId?: string;  // 이 요청을 보내는 기기 id. 주면 큐와 **동일한 3단 해석**(기기 지정 PL → 매장 활성 PL → 본사 기본 PL, 공용 `StorePlaylistResolver`)으로 편집 대상 PL 을 정한다. 생략하면 종전 2단(매장 활성 → 본사 기본) — 기기 지정 PL 로 재생 중인 PC 에서는 저장이 200 이어도 **음악이 안 바뀌는 조용한 no-op**. 타 매장·회수된 기기 id 는 400 이 아니라 조용한 폴백.
}

점장 시간대별 시간표(SPEC #171 FE-2, 기능 문서). override 가 있으면 그 구간 + isOverride=true, 없으면 본사 기본 + isOverride=false. 대상 PL 없으면 playlistId=null·playlistName=null·entries=[]. SPEC #178 — deviceId query 는 4종 (getStoreSchedule·customizeStoreSchedule·setStoreSchedule·deleteStoreSchedule) 모두에 있다(각각 GetStoreScheduleParams·CustomizeStoreScheduleParams·SetStoreScheduleParams·DeleteStoreScheduleParams — shape 동일). FE 는 기기가 등록돼 있으면 조회·시딩·저장·해제 전부에 싣는다. playlistName 은 편집 대상 식별용 — 시간표는 활성 PL 단위라 PL 이 바뀌면 편집 대상도 바뀌는데, 화면만 보고는 어느 PL 의 시간표인지 알 수 없었다. availableLibraries = 활성 PL 에 담긴 라이브러리 전체(그리드 브러시 후보). ⚠️ stale 경계: entrieslibraryId 는 쓰기 시점에만 availableLibraries 부분집합이 보장된다 — 쓰기 후 라이브러리가 PL 에서 제거/soft-delete 되면 entries ⊄ availableLibraries 가 될 수 있다(그때 libraryName=null). FE 는 그런 stale entry 를 회색 “삭제된 라이브러리” 로 방어 렌더하고 저장 시 걸러낸다.

SetStoreScheduleRequest / ScheduleEntryRequest (#171 — PUT /api/v1/store/me/schedule)

// generated `packages/api-client/src/generated/schemas/setStoreScheduleRequest.ts`
SetStoreScheduleRequest {
  entries: ScheduleEntryRequest[]; // 전체 교체(1~48개, @minItems 1 · @maxItems 48)
}
 
ScheduleEntryRequest {
  startMinute: number; // 0~1410, 30분 배수 (KST)
  endMinute: number;   // 30~1440, 30분 배수, start<end, 반열림 [start,end)
  libraryId: string;   // 해당 PL 의 멤버여야 함
}

매장 override 시간표 전체 교체 요청. @minItems 1 이라 빈 배열 불가(하루 전체 셔플은 DELETE 되돌리기로). FE 클라이언트 zod/가드는 이 제약(30분 배수·[0,1440]·팔레트 멤버)과 일치시킨다. 400 SCHEDULE_INVALID_BOUNDS·400 SCHEDULE_LIBRARY_NOT_MEMBER·409 SCHEDULE_TIME_OVERLAP.

PlaylistScheduleResponse / PlaylistScheduleEntry (#171 — GET·PUT /api/v1/admin/playlists/{id}/schedule · GET·PUT /api/v1/hq/playlists/{id}/schedule)

// generated `packages/api-client/src/generated/schemas/playlistScheduleResponse.ts`
PlaylistScheduleResponse {
  playlistId: string;              // 대상 PL id
  entries: PlaylistScheduleEntry[]; // 시간표 구간(startMinute 오름차순, 비면 전체 셔플)
}
 
PlaylistScheduleEntry {
  startMinute: number;         // 구간 시작(하루 중 분, KST, 30분 배수)
  endMinute: number;           // 구간 종료(반열림 [start,end), 30분 배수)
  libraryId: string;           // 재생 라이브러리 id
  libraryName?: string | null; // 표시용 이름(없으면 null — stale entry 는 null)
}

PL 기본 시간표(운영사가 정하는 본사 기본). 운영사 getPlaylistSchedule(OPERATOR 편집)과 본사 getHqPlaylistSchedule(HQ_MANAGER 편집)이 동일 DTO 를 공유한다. 매장 시간표(StoreScheduleResponse)와 달리 availableLibraries 를 응답에 싣지 않는다 — 편집 팔레트는 운영사 화면이 이미 조회하는 담긴 라이브러리 (listPlaylistLibraries)를, 본사 화면은 상세 libraries(+ entries.libraryName)를 라벨 소스로 쓴다. stale 경계는 매장과 동일(쓰기 후 라이브러리 제거 시 entries ⊄ 팔레트, libraryName=null → 회색 방어 렌더).

SetPlaylistScheduleRequest (#171 — PUT /api/v1/admin/playlists/{id}/schedule · PUT /api/v1/hq/playlists/{id}/schedule)

// generated `packages/api-client/src/generated/schemas/setPlaylistScheduleRequest.ts`
SetPlaylistScheduleRequest {
  entries: ScheduleEntryRequest[]; // 전체 교체(@minItems 0 · @maxItems 48)
}
// ScheduleEntryRequest 는 매장 시간표와 공유(위 참조).

PL 기본 시간표 전체 교체 요청. 매장(SetStoreScheduleRequest, @minItems 1)과 달리 @minItems 0 — 빈 배열 허용 = 시간표 해제(전 시간대 전체 셔플로 회귀). 운영사 setPlaylistSchedule(OPERATOR)와 본사 setHqPlaylistSchedule(HQ_MANAGER)이 동일 DTO·동작으로 같은 본사 기본 시간표를 수정한다(매장 조정은 점장 소관). 400 SCHEDULE_INVALID_BOUNDS·400 SCHEDULE_LIBRARY_NOT_MEMBER·409 SCHEDULE_TIME_OVERLAP·404 PLAYLIST_NOT_FOUND.

Store Device DTOs (#178 — /api/v1/store/devices*)

한 매장에서 PC 여러 대가 같은 점장 계정으로 음악을 트는 현실을 담는 층(SPEC #178). 계정으로는 기기를 구분할 수 없으므로 클라이언트가 발급해 localStorage 에 두는 UUID(deviceKey) 를 매장 스코프에서 등록해 식별하고, 그 위에 기기별 활성 PL·재생 상태·재생 로그·방송 ack 를 얹는다. 화면은 Store Devices 참조.

RegisterStoreDeviceRequest / StoreDeviceResponse / StoreDeviceListResponse (#178 — POST·GET /api/v1/store/devices)

// generated `schemas/registerStoreDeviceRequest.ts`·`storeDeviceResponse.ts`·`storeDeviceListResponse.ts`
RegisterStoreDeviceRequest {
  deviceKey: string;              // 클라 발급 기기 식별자(UUID 권장, @maxLength 64 · non-blank). 같은 값 재호출 = 멱등
  label?: string | null;          // 기기 이름(@maxLength 50). 생략하면 서버가 "기기 N" 으로 채움
}
 
StoreDeviceResponse {
  id: string;                     // 서버 발급 기기 id — 이후 요청의 deviceId
  label: string;                  // 기기 이름
  lastSeenAt: string;             // 마지막 신호 시각(등록·재생 보고 시 갱신, ISO-8601)
  createdAt: string;              // 등록 시각(ISO-8601)
}
 
StoreDeviceListResponse {
  items: StoreDeviceResponse[];   // 등록 순(created_at), 회수된 기기 제외. 상한 4대라 페이지네이션 없음
}

deviceKey 는 응답에 담기지 않는다 — 클라이언트가 이미 갖고 있고 노출 이득이 없다. 등록은 멱등(같은 (storeId, deviceKey) 면 기존 행 반환 + lastSeenAt 갱신)이라 새로고침·재부팅으로 기기가 늘지 않는다. 상한 4대 초과는 409 DEVICE_LIMIT_EXCEEDED — 서버 자동 회수는 없고 점장이 목록에서 정리한다(오프라인과 폐기를 구분할 수 없어 자동 회수는 재생 중인 기기를 뺏는다). lastSeenAt 은 그 정리 판단의 유일한 근거다.

RenameStoreDeviceRequest (#178 — PATCH /api/v1/store/devices/{id} body)

// generated `schemas/renameStoreDeviceRequest.ts`
RenameStoreDeviceRequest {
  label: string;                  // 새 기기 이름(1~50자 · non-blank)
}

미존재·이미 회수됨·타 매장 기기는 전부 404 STORE_DEVICE_NOT_FOUND(존재 은닉). 회수 (DELETE .../{id})는 204·soft-delete 이며 같은 404 시맨틱을 공유한다.

StoreDevicePlaylistResponse / SetStoreDevicePlaylistRequest (#178 — GET·PATCH /api/v1/store/devices/{id}/active-playlist)

// generated `schemas/storeDevicePlaylistResponse.ts`·`setStoreDevicePlaylistRequest.ts`
StoreDevicePlaylistResponse {
  deviceId: string;               // 기기 id
  playlistId?: string | null;     // 지정된 PL id. null = 지정 없음 → 매장 기본(매장 활성 PL → 본사 기본 PL)
  appliedAt?: string | null;      // 지정 시각(지정 이력이 없으면 null)
}
 
SetStoreDevicePlaylistRequest {
  playlistId?: string | null;     // 이 기기가 재생할 PL. null = 지정 해제(매장 기본으로 복귀)
}

같은 매장이라도 기기마다 다른 목록을 튼다(D3 — 종전엔 활성 PL 이 store.active_playlist_id 단일 값이라 한 PC 에서 바꾸면 다른 PC 도 따라갔다). 자기 본사 PL 이 아니거나 삭제된 PL 이면 404 PLAYLIST_NOT_FOUND(존재 은닉) · 기기 미존재·회수됨·타 매장은 404 STORE_DEVICE_NOT_FOUND. 반영은 다음 큐 조회부터이며, 큐 해석 우선순위는 StorePlaybackQueueResponse.source 참조. store.active_playlist_id 는 그대로 유지된다(D13 expand→migrate→contract — 구버전 클라이언트와 기기 지정이 없는 기기의 폴백 소스).

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·MISSED) distinct 매장 수
}

operationId getHqUndeliveredToday(D4). 집계 조건은 오늘(KST) 윈도우 · status IN (PENDING, MISSED) · is_hq_origin = true 다(감사 #11·#11-a):

  • MISSED 포함 — MISSED 는 “들리지 않은 채 종결”(grace 초과 자동 만료 · 점장 player 의 재생 실패·폐기 보고)이라 미도달이다. PENDING 만 세면 실패 ack 로 MISSED 가 된 송출이 카운트에서 빠져 “모두 도달” 로 보이는 역전이 생긴다.
  • 점장 직접 송출 제외 — 점장 즉시방송·점장 예약은 같은 hq 버킷에 들어가지만 본사 송출이 아니다. 이 카드는 본사 송출의 도달률 지표다.
  • PLAYED(실제 재생)·SCHEDULED(미도래)·CANCELED(본사 취소)는 제외.

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 강조(카드를 버튼화 → 클릭 시 아래 HqUndeliveredStoreListResponse 드릴다운 다이얼로그, SPEC #162), 0 이면 “모두 도달” 비강조·비링크(false affordance 제거).

HqUndeliveredStoreListResponse (#162 H11 — GET /api/v1/hq/dashboard/undelivered-today/stores)

// generated `packages/api-client/src/generated/schemas/hqUndeliveredStoreListResponse.ts`
{
  items: HqUndeliveredStoreItem[];  // 본사 송출 미도달(PENDING·MISSED) distinct 매장(매장명 오름차순)
  total: number;                    // count endpoint 값과 정합
}
 
// generated `packages/api-client/src/generated/schemas/hqUndeliveredStoreItem.ts`
interface HqUndeliveredStoreItem {
  storeId: string;    // 매장 상세 라우팅용(/admin/stores/{storeId})
  storeName: string;  // 매장명
}

operationId getHqUndeliveredTodayStores(#162 H11). count 카드(getHqUndeliveredToday)의 드릴다운getHqUndeliveredToday완전히 동일한 조건(오늘 KST 윈도우 · status IN (PENDING, MISSED) · is_hq_origin = true · hqId 격리)에 Store 조인으로 매장명을 붙인 distinct store 목록(한 매장 여러 미도달=1행). 두 endpoint 가 같은 술어를 쓰므로 total == count 값이 항상 정합하다 — 한쪽만 필터를 바꾸면 카드 숫자와 목록 길이가 어긋난다(감사 #11-a). 매장명 오름차순(동명 시 storeId 타이브레이크). query page(0-base)·size(1..100 clamp, 기본 20). hqId 토큰 주체 도출(타 본사 미반영). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqUndeliveredTodayStores. apps/space /admin 대시보드 미도달 카드 클릭 시 열리는 UndeliveredTodayDialog(apps/space/src/app/admin/undelivered-today-dialog.tsx, enabled: open 로 열릴 때만 조회)가 소비 — 로딩 스피너·에러 ErrorState(SPEC #154, 재시도=refetch)·빈 상태·행별 매장 상세 링크·페이지네이션(total > size). 카드가 /admin/audit(delivery 필터 없는 송출 행위 로그)로 보내 “어느 매장인지” 볼 수 없던 dead-end(감사 H11) 를 해소한다.

HqPlaybackStatusResponse / HqPlaybackStatusStoreItem (#172 FE-B — GET /api/v1/hq/dashboard/playback-status)

// generated `packages/api-client/src/generated/schemas/hqPlaybackStatusResponse.ts`
{
  playingCount: number;   // 지금 재생 중(PLAYING) 매장 수
  silentCount: number;    // 무음(SILENT) 매장 수
  offlineCount: number;   // 신호 끊김(OFFLINE, 서버 파생) 매장 수
  total: number;          // 산하 미폐점 매장 전체 수. ⚠️ PAUSED 는 세 카운트 밖 → playing+silent+offline != total 가능(합계로 쓰지 말 것)
  items: HqPlaybackStatusStoreItem[];  // 매장별 상태(매장명 오름차순)
}
 
// generated `packages/api-client/src/generated/schemas/hqPlaybackStatusStoreItem.ts`
interface HqPlaybackStatusStoreItem {
  storeId: string;               // 매장 상세 라우팅용(/admin/stores/{storeId})
  storeName: string;             // 매장명
  state: HqPlaybackStatusStoreItemState;  // PLAYING·PAUSED·SILENT·OFFLINE(OFFLINE 만 서버 파생)
  lastSeenAt?: string | null;    // 마지막 heartbeat 시각. 한 번도 보고 안 한 매장은 null
  currentMusicId?: string | null;    // 현재 재생 곡 id. OFFLINE·무음이면 null
  currentMusicTitle?: string | null; // 현재 재생 곡 제목. OFFLINE·무음·삭제 곡이면 null
}

operationId getHqPlaybackStatus(#172 FE-B). 미도달(방송 도달률)과 별개인 음악 재생 축 — “산하 매장이 지금 음악을 틀고 있나”를 답한다. OFFLINE 은 서버가 마지막 heartbeat staleness 로 파생(점장 player 는 보고하지 않는 값 — PLAYING/PAUSED/SILENT 만 명시 보고). SPEC #178 — 소스가 기기 단위 우선으로 바뀌었다: 기기 행(store_device_playback_status)이 하나라도 있는 매장은 그 기기들을 PLAYING > PAUSED > SILENT > OFFLINE 우선순위로 접고(“하나라도 재생 중이면 그 매장은 재생 중” — 대시보드의 목적이 “음악이 나가고 있는가” 판단이라 가장 활동적인 상태가 매장을 대표해야 한다), 기기 행이 없는 매장만 종전 store_playback_status(매장당 1행)로 폴백한다. 회수된 기기의 유령 행은 store_device 조인 + deleted_at IS NULL 로 제외. 각 기기는 먼저 개별 staleness 로 OFFLINE 파생을 거치므로 꺼진 PC 한 대가 매장 전체를 OFFLINE 으로 끌어내리지 않고 stale 한 PC 가 PLAYING 을 위조하지도 않는다. lastSeenAt = 기기들 중 가장 최근 신호 · 현재 곡 = 그 상태를 결정한 기기 중 첫 행(repository 정렬이 last_heartbeat_at DESC 라 결정적 · 곡이 없는 기기가 앞서면 곡이 있는 같은 상태의 기기를 우선 · OFFLINE 이면 감춤). 종전엔 매장 행 PK 가 store_id마지막에 보고한 기기가 앞 기기를 덮어써 새로고침마다 상태·현재 곡이 왕복했다(V59 가 “조회 병합은 후속”으로 남겨둔 expand 단계를 닫은 변경). hqId 토큰 주체 도출(타 본사 0). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqPlaybackStatus. apps/space /admin 대시보드 “재생 상태” 카드(PlaybackStatusCard client island)가 60초 폴링(refetchInterval:60s·refetchIntervalInBackground:false — 운영자가 화면을 볼 때만 의미 있는 지표)으로 소비 — “지금 재생 중 N/전체 · 무음 M · offline K”, 무음·offline>0 이면 warn 강조(카드를 버튼화PlaybackStatusDialog 드릴다운: 카드 query items 를 필터해 무음·offline 매장명·상태·마지막 접속(KST)·현재곡·행별 [매장 상세]. 별도 fetch 없이 카드 items 를 props 로 넘긴다), 0 이면 “모두 재생 중” 비강조·비버튼. 미도달 카드 옆에 나란히 배치(방송·음악 두 축).

HqPlaybackSummaryResponse / HqPlaybackSummarySeriesItem / HqPlaybackSummaryStoreItem / HqPlaybackSummaryTotalDto (#175 · #172 FU-2 — GET /api/v1/hq/playback/summary)

// generated `packages/api-client/src/generated/schemas/hqPlaybackSummaryResponse.ts`
interface HqPlaybackSummaryResponse {
  bucket: "DAY" | "WEEK" | "MONTH";  // series 추이 버킷 단위(total·stores 는 버킷 무관 기간 전체 합)
  from: string;                       // 조회 시작일(KST, 포함, YYYY-MM-DD)
  to: string;                         // 조회 종료일(KST, 포함)
  total: HqPlaybackSummaryTotalDto;   // 기간 전사 합
  series: HqPlaybackSummarySeriesItem[];  // 전사 버킷별 추이(bucketStart asc, 차트용)
  stores: HqPlaybackSummaryStoreItem[];   // 매장별 기간 합(playedMsTotal desc, 테이블용·폐점 포함)
}
 
// hqPlaybackSummaryTotalDto.ts — 신탁/비신탁 분리
interface HqPlaybackSummaryTotalDto {
  playedMsTotal: number;   // 총 재생시간(ms) — 전 매장·전 기간
  playedMsTrust: number;   // 신탁 곡 재생시간(ms)
  trackCount: number;      // 총 재생 곡수
  trackCountTrust: number; // 신탁 곡 재생 곡수
}
 
// hqPlaybackSummarySeriesItem.ts — 차트용(trackCountTrust 없음)
interface HqPlaybackSummarySeriesItem {
  bucketStart: string;     // 버킷 시작 일자(KST). DAY=당일·WEEK=주 시작(월)·MONTH=월 초
  playedMsTotal: number;
  playedMsTrust: number;
  trackCount: number;
}
 
// hqPlaybackSummaryStoreItem.ts — 테이블용
interface HqPlaybackSummaryStoreItem {
  storeId: string;         // 매장 상세 라우팅용(/admin/stores/{storeId})
  storeName: string;       // 매장명(폐점 매장도 표기)
  playedMsTotal: number;
  playedMsTrust: number;
  trackCount: number;
  trackCountTrust: number;
}

operationId getHqPlaybackSummary(#175, #172 FU-2). 롤업 playback_daily_rollup(매장×일 KST) 위의 조회 endpoint(데이터 모델 변경 없음) — 본사가 산하 매장 재생을 일/주/월 로 조망한다. seriesdate_trunc 로 GROUP BY 해 전사 합산(DAY=day_kst 그대로·WEEK=월요일 시작·MONTH=월 초), total·stores 는 버킷과 무관한 기간 전체 합이다. 폐점 매장 포함(과거 재생분 보존, storeName 은 현재 store 조인 — soft-deleted 매장명도 표기). 검증: from<=to·범위 상한(최대 1년) 초과 400·bucket enum 오값 400. 준수율(영업시간 대비)은 이번 제외(FU-1 영업시간 저장 선행 후 이 응답에 열 추가). hqId 토큰 주체 도출(타 본사 0). claim↔DB 재검증 403 · 미인증 401. generated 훅 useGetHqPlaybackSummary. apps/space /admin/playback 재생 리포트(PlaybackReportClient)가 소비 — 기간 컨트롤(bucket 일/주/월 토글 + from·to date, 기본 DAY·최근 30일·상한 366일 클라 사전검증) + 전사 KPI(총 재생시간 h:m·총 곡수·신탁 비율) + 커스텀 SVG 추이 차트(PlaybackTrendChart — 신탁/일반 스택 막대, 운영사 signup-trend 패턴 미러·차트 라이브러리 없음) + 매장별 테이블(재생시간 desc·신탁 분리·매장 상세 링크·ErrorState 재시도·빈 상태). 리포트(실시간 아님)라 refetchIntervalInBackground:false.

PlaybackLogItemDto / PlaybackLogListResponse (#176 · #172 FU-3 — 재생 로그 뷰어)

// generated `packages/api-client/src/generated/schemas/playbackLogItemDto.ts`
interface PlaybackLogItemDto {
  playedAt: string;        // 재생 시작 시각(started_at, ISO-8601)
  hqId: string;            // 본사 id
  hqName: string;          // 본사명
  storeId: string;         // 매장 id
  storeName: string;       // 매장명(폐점 매장도 표기)
  musicId: string;         // 곡 id
  musicTitle: string;      // 곡명(삭제된 곡도 표기)
  musicSource: "AI" | "TRUST";  // 음원 타입(업로드 시 지정·불변)
  isTrust: boolean;        // 신탁 곡 여부(= musicSource == TRUST)
  playedMs: number;        // 재생 시간(밀리초)
}
 
// generated `packages/api-client/src/generated/schemas/playbackLogListResponse.ts`
interface PlaybackLogListResponse {
  items: PlaybackLogItemDto[];  // 재생 로그 행 목록(started_at 내림차순)
  page: number;                 // 현재 페이지 번호(0-base)
  size: number;                 // 페이지 크기
  total: number;                // 필터 매칭 전체 행 수
}

운영사 listAdminPlaybackLogs(플랫폼 전체·hqId/storeId 로 좁힘) 와 본사 listHqPlaybackLogs(hqId 격리·자기 산하만) 가 동일 응답 shape 을 쓴다(운영사 본사 컬럼도 노출·본사 화면은 본사명 컬럼 생략). CSV 내보내기(exportAdminPlaybackLogs·exportHqPlaybackLogs)는 같은 필터의 전체 행(상한 50,000)을 text/csv 로 — 별도 DTO 없이 서버 스트림(FE 는 downloadCsvFromBackend BFF 경유 blob). play_log(#172 V55 적재·곡당 1행) 위의 조회로 데이터 모델 변경 없음. 정렬 started_at desc → id desc 고정. 신탁(TRUST) 음원 KOMCA 신고 근거(공식 신고 포맷은 외부 규격 게이트 — 이번은 raw 조회·CSV). apps/admin /settings/trust-playback-logs(PRD 18-5) · apps/space /admin/playback/logs 뷰어가 소비. musicSource enum 은 PlaybackLogItemDtoMusicSource(AI/TRUST).

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 CreateHqStoreResponse{store, managerAccount?} envelope — SPEC #184 로 flat HqStoreDetailResponse 에서 바뀐 breaking change(발급 결과를 조회·편집 공용 상세 DTO 에 섞지 않는다). FE 는 data.store.id 로 새 매장 상세(/admin/stores/{id})에 push 한다 — 예전 data.id 접근을 그대로 두면 /admin/stores/undefined 로 깨진다. 검증 위반 시 400 HQ_STORE_INVALID_FIELD(jakarta validation @Valid + service 명시 가드). 정지(SUSPENDED) 본사는 등록 거부 → 403 AUTH_HQ_SUSPENDED (login 차단이 first line, service 가드 second line — D5). 동일 본사 내 name 중복 허용(분점 패턴, D6). SPEC #184 (D1·D4) — 점장 계정 발급이 통합됐다: managerEmail 이 채워지면 같은 트랜잭션에서 STORE_MANAGER 계정이 생성되고 계정 설정 메일이 커밋 후 발송된다(임시 비밀번호는 서버 생성 · 응답 미노출 — D6). 미입력이면 “점장 미정”(D2) → POST /api/v1/hq/stores/{storeId}/managers 로 사후 발급. 이메일 중복 → 409 DUPLICATE_EMAIL + 매장 롤백(D9, 고정 문구 — 계정 열거 차단). rate limit STORE_PROVISION 20회/분 → 429 RATE_LIMITED. audit hq_audit_log 기록: HqAuditAction.HQ_STORE_CREATED(발급 시 HQ_STORE_MANAGER_ISSUED 별도 row 추가) + 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;         // 등록 실패 행 수
  // SPEC #184 — 점장 계정 발급 결과 합계.
  managerAccountCreatedCount: number;  // 계정까지 함께 발급된 행 수. successCount 와의 차이 = "점장 미정" 매장 수
  setupEmailFailedCount: number;       // 계정은 생겼지만 setup 메일 발송에 실패한 행 수(재발송 필요)
  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 · 이메일 중복은 DUPLICATE_EMAIL)
  errorMessage?: string | null;        // 실패 사유 안내 문구(사용자 안전 문구)
  // SPEC #184 — 행별 점장 계정 발급 결과.
  managerAccountCreated: boolean;      // 의도적 파생 필드(= setupEmailSent != null). 실패 행은 항상 false
  setupEmailSent?: boolean | null;     // 계정 미발급 행은 null · 발송 성공 true · **발송 실패 false**
}

operationId bulkCreateHqStores. HQ_MANAGER-only. consumes multipart/form-data(@RequestPart("file")). #106 단일 등록을 행 단위로 미러한다. SPEC #184 (D6) — “담당자이메일” 컬럼이 점장 로그인 ID 로 승격됐다: 채워진 행은 매장 + 점장 계정이 함께 생성되고 임시 비밀번호는 서버 생성 + 응답 미노출(setup 메일 전용 진입). setup 메일은 행 트랜잭션 밖에서 루프 종료 후 일괄 발송한다(행마다 외부 왕복이면 1000행 상한에서 BFF 타임아웃 + 커넥션 점유). rate limit 은 별도 그룹 BULK_PROVISION 계정당 3회/분 → 초과 429 RATE_LIMITED. 이메일 중복은 그 행만 errorCode=DUPLICATE_EMAIL 로 실패한다. 부분 실패 = 행별 독립 처리 + 성공분 커밋 + 결과 리포트(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).

CreateHqStoreResponse / HqStoreManagerIssueResult (#184 — POST /api/v1/hq/stores 응답 envelope)

// generated `schemas/createHqStoreResponse.ts`·`hqStoreManagerIssueResult.ts`
// ⚠️ SPEC #184 breaking — 이전에는 201 body 가 flat `HqStoreDetailResponse` 였다.
CreateHqStoreResponse {
  store: HqStoreDetailResponse;                 // 생성된 매장 상세(read-back)
  managerAccount?: HqStoreManagerIssueResult;   // 계정을 함께 발급했을 때만. "점장 미정"이면 null
}
 
HqStoreManagerIssueResult {
  managerAccountId: string;        // 발급된 점장 계정 id
  email: string;                   // 발급된 로그인 ID(정규화된 이메일)
  setupEmailSent?: boolean | null; // 발송 성공 true · **발송 실패 false**(계정 진입 경로 없음 → 재발송 필요)
}

발급 결과를 조회·편집 공용 상세 DTO(HqStoreDetailResponse)에 섞지 않기 위한 envelope 다. FE 는 data.store.id 로 새 매장 상세에 진입하고, data.managerAccount?.setupEmailSent === false 면 상세로 넘어가기 전에 “계정 설정 안내 메일 미발송” 을 danger 로 알린다(본사는 재발송 권한이 없으므로 — 재발송은 OPERATOR-only — 운영사 문의로 잇는다).

IssueHqStoreManagerRequest / HqStoreManagerIssueResponse (#184 — POST /api/v1/hq/stores/{storeId}/managers)

// generated `schemas/issueHqStoreManagerRequest.ts`·`hqStoreManagerIssueResponse.ts`
IssueHqStoreManagerRequest {
  name?: string | null;  // 점장 이름 · ≤50 · 선택
  email: string;         // 점장 로그인 ID · @Email + ≤255 · **필수**
  // ⚠️ tempPassword 필드가 **없다** — 임시 비밀번호는 서버 생성이고 응답에도 노출하지 않는다(D6).
  //    계정 설정(setup) 메일이 유일한 진입 경로다.
}
 
HqStoreManagerIssueResponse {
  storeId: string;
  managerAccount: HqStoreManagerIssueResult;
}

operationId issueHqStoreManager. HQ_MANAGER-only(SPEC #184 D4 신규 권한) — 본사가 자기 산하 매장에 점장 계정을 사후 발급한다. 생성 시점을 놓친 매장의 유일한 복구 경로다: “점장 미정” 등록(D2) · 이메일 중복으로 계정 없이 재등록(D9) · 점장 회수(WITHDRAWN) 후 재발급(D8 / 감사 AC-F1). 대상은 GET /api/v1/hq/stores?hasManagerAccount=false 로 색출한다. 스코프는 PrincipalScopeGuard 로 재검증하고 타 본사·미존재 매장은 403 PRINCIPAL_SCOPE_MISMATCH 로 은닉(존재 유무 미노출). 정지 본사 403 AUTH_HQ_SUSPENDED · 이메일 중복 409 DUPLICATE_EMAIL(고정 문구 — 임의 이메일의 409/201 관측으로 플랫폼 계정을 열거하지 못하게 입력 이메일을 담지 않는다) · rate limit STORE_PROVISION 20회/분 초과 429 RATE_LIMITED. 매장당 복수 점장 계정 허용. 발급마다 audit HqAuditAction.HQ_STORE_MANAGER_ISSUED(총 18종) + HqAuditTargetType.STORE. generated 훅 useIssueHqStoreManager. apps/space /admin/stores/[id] 점장 정보 섹션 [점장 발급] 다이얼로그로 소비(hq-store-manager-issue-dialog.tsx — 미발급·회수 상태에서만 노출, 폐점 매장 제외). 전용 시안 부재 → atom-grounded(#106 폼 idiom + 본사 다이얼로그 골격).

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·#152 — 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
  activeScheduleCount: number;  // (#152) 연결된 활성(ACTIVE) 반복예약 수. 0=없음. 삭제 시 함께 취소될 건수 — FE 삭제 다이얼로그 경고용.
}

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

TtsAnnouncementListResponse / TtsAnnouncementListItem (#061·#063·#066·#097·#152 — 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매장)` 보조 표기.
  activeScheduleCount: number;          // (#152) 연결된 활성(ACTIVE) 반복예약 수(CANCELED 제외). 0=없음. 삭제 시 cascade 취소될 건수 — FE 삭제 다이얼로그가 "반복예약 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`·`dispatchHistoryItemMissedReason.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·#143 5종) "SCHEDULED" | "PENDING" | "PLAYED" | "CANCELED" | "MISSED"
  createdAt: string;                      // 송출 row 생성 시각 (ISO-8601, 예약 등록 시각 = SCHEDULED 의 등록 시각)
  playedAt?: string | null;               // **실제 재생 완료** ack 시각 (ISO-8601). SCHEDULED/PENDING/CANCELED/MISSED 는 null — 실패·폐기 ack 는 채우지 않는다(정상 송출 집계의 단일 소스)
  // (감사 #11) 미도달 사유·재시도 진단 4필드 — ack outcome 계약 확장으로 신설.
  missedReason?: DispatchHistoryItemMissedReason | null; // "EXPIRED" | "PLAYBACK_FAILED" | "SKIPPED". MISSED 가 아닌 송출은 항상 null
  missedAt?: string | null;               // MISSED 종결 시각 (ISO-8601). MISSED 가 아니거나 V49 이전 만료 row 면 null
  playbackFailureCount: number;           // 점장 player 의 재생 실패 보고 누적 횟수(NOT NULL, 기본 0). **status=PENDING 인데 0보다 크면 지금 서버가 재전달 중**(재시도 중)이라는 운영 진단 신호
  lastFailedAt?: string | null;           // 마지막 실패 보고 시각 (ISO-8601). 실패 보고가 없으면 null. `missedAt` 과 다르다 — 임계치 미만이면 missedAt 은 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 만 카운트
  missed: number;                         // (감사 #11) 미재생 종결(MISSED) row 수 — grace 초과 자동 만료 + 점장 player 의 재생 실패·폐기 보고 합계
  canceled: number;                       // (감사 #11) 본사 취소(CANCELED) row 수 — 도달 의도가 철회된 송출(미도달과 구분)
  distinctStoreCount: number;             // (SPEC #075) 고유 매장 수 — COUNT(DISTINCT storeId), 매장 0건이면 0
}
// 감사 #11 — 상태 5종 분해가 완성되어 `scheduled + pending + played + missed + canceled == total` 이 성립한다
// (`distinctStoreCount` 는 매장 단위 지표라 이 합에 들어가지 않는다).

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 동일).

미도달 사유·재시도 표기 (감사 #11): DispatchHistoryDialog 는 MISSED 행의 pill 문구에 사유를 붙여 미재생(만료) / 미재생(재생 실패) / 미재생(건너뜀) 으로 노출하고(missedReason null 이면 “미재생” 폴백, 종결 시각 missedAt 은 title tooltip), status=PENDING && playbackFailureCount > 0 인 행에는 “재시도 중 · 실패 N회” 보조 줄을 붙인다(마지막 실패 lastFailedAt 은 tooltip). 재시도 정책(3회 누적 시 MISSED(PLAYBACK_FAILED) 종결)은 BE 소유 — FE 는 표시만 하고 판정하지 않는다. 집계 헤더에는 missed·canceled chip 이 추가돼 5종 합 == total 로 잔차 없이 분해된다. missedReason 값 도메인은 DispatchMissedReason 과 동일 — generated 타입은 endpoint scope DispatchHistoryItemMissedReason(nullable 이라 | null 포함).

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 가 N곡마다 1회 자동 재생” 하는 다른 도메인이다. #093 은 백본 + 본사 관리 UI 슬라이스이고, 점장 player 사이클 재생은 SPEC #094·#095·#103·#104 에서 완료(재생 모델은 #141 오버레이 동시재생 — 음악은 멈추지 않는다). CM 라이브러리 묶음만 후속(F4). #174: 등록·교체가 URL 입력 → MP3 파일 업로드로 전환(서버 검증· blob 저장·audioUrl 자동 채움), audit HQ_COMMERCIAL_CREATED·HQ_COMMERCIAL_FILE_REPLACED 추가.

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;          // 서버가 파일 업로드(#174) 후 채운 Azure blob URL (`{prefix}/commercials/{id}.mp3`)
  durationSeconds: number;   // 오디오 길이(초) — 1..3600
  isActive: boolean;         // 활성 여부 — 점장 player 사이클 재생 대상 토글(#094 라운드로빈 후보 필터)
  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
};

CreateHqCommercialBody · CreateHqCommercialParams (#093·#174 — POST /api/v1/hq/commercials, multipart)

// generated `schemas/createHqCommercialBody.ts` (multipart body)
CreateHqCommercialBody {
  file: Blob;               // CM송 MP3 파일
}
// generated `schemas/createHqCommercialParams.ts` (query)
CreateHqCommercialParams {
  title: string;            // 1~200
  durationSeconds: number;  // 1~3600 (클라이언트 추출)
}

#174: URL 입력 → 파일 업로드 전환. 구 JSON CreateHqCommercialRequest{title,audioUrl, durationSeconds} 는 OpenAPI 에서 제거됐다. 이제 consumes=multipart/form-datafile 은 body, title·durationSeconds 는 query. 서버가 파일을 검증(MP3 magic byte·audio/mpeg·20MB· 비어있지 않음, ID3 rewrite 미경유 as-is 저장 · #174 D2)한 뒤 Azure blob({prefix}/commercials/{id}.mp3)에 저장하고 audioUrl 을 채운다. durationSeconds 는 FE 가 Web Audio 로 추출(#174 D4). 201 + HqCommercialDetailResponse, 생성 시 isActive=true 기본. 에러 400 두 종류: 파일 문제(빈· 비-MP3·20MB 초과) = COMMERCIAL_INVALID_FILE, title/durationSeconds 범위 위반 = 전역 검증 400(별개 code). hqId·createdAt·updatedAt 은 BE 가 토큰 주체·now() 로 채운다(타 본사 격리). generated 훅 useCreateHqCommercial({data:{file}, params:{title,durationSeconds}}).

ReplaceHqCommercialFileBody · ReplaceHqCommercialFileParams (#174 — PUT /api/v1/hq/commercials/{id}/file, multipart)

// generated `schemas/replaceHqCommercialFileBody.ts` (multipart body)
ReplaceHqCommercialFileBody {
  file: Blob;               // 새 CM송 MP3 파일
}
// generated `schemas/replaceHqCommercialFileParams.ts` (query)
ReplaceHqCommercialFileParams {
  durationSeconds: number;  // 1~3600 (클라이언트 재추출)
}

#174 신규 — 오디오 파일 교체. 음원 PUT /{id}/file 미러 — 같은 blob key ({prefix}/commercials/{id}.mp3) 덮어쓰기(orphan 없음)로 audioUrl(동일 URL·내용 교체)· durationSeconds 갱신. 200 + HqCommercialDetailResponse. 파일 검증은 create 와 동일 — 파일 문제 400 COMMERCIAL_INVALID_FILE / durationSeconds 범위 위반 = 전역 검증 400. 기존 title·isActive 는 불변(PATCH 로 별도 관리). hqId ≠ 주체/미존재/삭제 → 404 COMMERCIAL_SONG_NOT_FOUND(은닉). generated 훅 useReplaceHqCommercialFile ({id, data:{file}, params:{durationSeconds}}). FE 상세 화면 오디오 교체 섹션(파일 선택 + duration 재추출).

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·durationSeconds) 교체는 별도 endpoint PUT /{id}/file(#174) — PATCH 는 메타(title·isActive)만. 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 는 상세 마지막 섹션의 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[];               // 생성순 오름차순(시간순)
  attachments: TicketAttachmentItem[];           // 생성순 오름차순(없으면 []) — visibility 필드 없음
}

타 본사 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단계만

HQ 하위 매장 CS DTOs (#173 — apps/space /admin/store-support)

본사가 산하 매장 점장이 올린 CS(#112, storeId≠null)를 조회·처리하는 별도 DTO 군이다. 위 HQ CS 티켓 DTOs(#086, /api/v1/hq/tickets/*)는 본사↔운영사 CS(본사 본인 발신)이고, 여기는 본사→하위 매장 CS(/api/v1/hq/store-tickets/*)로 도메인이 다르다. 처리 정책(#173 D1): 본사가 조회·답변(REPLY)·상태 변경(표준 전이 전체) 가능, assign·우선순위 조정은 운영사 전담(해당 endpoint·DTO 없음). 매장명·작성 점장 이메일이 노출되고, 댓글은 3-role(STORE_MANAGER·OPERATOR·HQ_MANAGER)이며 운영자 INTERNAL 메모는 제외된다. category 는 nullable(| null — 기존 운영자/본사 티켓 하위호환, 점장 작성은 필수).

HqStoreTicketListItem / HqStoreTicketListResponse (#173 — GET /api/v1/hq/store-tickets)

// generated `schemas/hqStoreTicketListItem.ts` · `hqStoreTicketListResponse.ts`
HqStoreTicketListItem {
  id: string;
  title: string;
  status: HqStoreTicketListItemStatus;       // OPEN | IN_PROGRESS | RESOLVED | CLOSED
  priority: HqStoreTicketListItemPriority;   // URGENT | HIGH | NORMAL | LOW
  category?: HqStoreTicketListItemCategory;  // @nullable — PLAYBACK | BROADCAST | BILLING | ACCOUNT | OTHER | null
  storeId: string;                           // 작성 매장 id
  storeName: string;                         // 작성 매장명
  commentCount: number;
  createdAt: string;                         // ISO-8601
  updatedAt: string;
}
HqStoreTicketListResponse { items: HqStoreTicketListItem[]; page: number; size: number; total: number; }

정렬 created_at DESC, id ASC. WHERE storeId≠null AND store.hq_id=:hqId(hqId 격리). FE 표 5컬럼: 매장명(+제목)·카테고리(nullable → ”—”)·상태 배지·우선순위 배지·작성 시각 KST. 카테고리 라벨은 점장 CS(#112)와 동일(재생·음악/방송·안내/결제·정산/계정·로그인/기타).

HqStoreTicketDetailResponse / HqStoreTicketCommentItem (#173 — GET /api/v1/hq/store-tickets/{id})

// generated `schemas/hqStoreTicketDetailResponse.ts` · `hqStoreTicketCommentItem.ts`
HqStoreTicketDetailResponse {
  id: string; title: string; body: string;
  status: HqStoreTicketDetailResponseStatus;
  priority: HqStoreTicketDetailResponsePriority;
  category?: HqStoreTicketDetailResponseCategory;   // @nullable
  storeId: string; storeName: string;
  submitterEmail: string;                           // 작성 점장 이메일 스냅샷
  createdAt: string; updatedAt: string;
  comments: HqStoreTicketCommentItem[];             // 생성순 오름차순, REPLY 만
  attachments: TicketAttachmentItem[];              // 운영자 INTERNAL 첨부 제외 · visibility 필드 없음
}
HqStoreTicketCommentItem {
  id: string; body: string;                         // @maxLength 5000
  authorRole: HqStoreTicketCommentItemAuthorRole;   // STORE_MANAGER | OPERATOR | HQ_MANAGER
  authorEmail: string; createdAt: string;
}

타 본사·타 매장(hqId 미소속)·미존재 전부 404 TICKET_NOT_FOUND(은닉). 운영자 INTERNAL 메모 제외(BE 필터). FE 채팅 스레드 3-role 시각: STORE_MANAGER=“점장”(좌·surface) · OPERATOR=“운영사”(좌·success 틴트) · HQ_MANAGER=“본사”(우·primary-soft — 나=처리 주체).

HqStoreTicketStatusChangeRequest / Response (#173 D1 — PATCH /api/v1/hq/store-tickets/{id}/status)

// generated `schemas/hqStoreTicketStatusChangeRequest.ts` · `...Response.ts`
HqStoreTicketStatusChangeRequest { status: HqStoreTicketStatusChangeRequestStatus; }   // 표준 전이 전체
HqStoreTicketStatusChangeResponse { status: HqStoreTicketStatusChangeResponseStatus; }

본사가 처리 주체 → 표준 전이 전체 허용(OPEN→IN_PROGRESS→RESOLVED→CLOSED + RESOLVED/CLOSED→IN_PROGRESS). 비허용 전이 409 TICKET_INVALID_STATUS_TRANSITION. FE 는 성공 후 getGetHqStoreTicketDetailQueryKey(id) invalidate. 우선순위 변경 DTO/endpoint 는 없다(운영사 전담).

AddHqStoreTicketCommentRequest (#173 — POST /api/v1/hq/store-tickets/{id}/comments)

// generated `schemas/addHqStoreTicketCommentRequest.ts`
AddHqStoreTicketCommentRequest { body: string; }   // @maxLength 5000 · NotBlank → 201 HqStoreTicketCommentItem(authorRole=HQ_MANAGER)

HqStoreSupportUnreadSignalResponse (#173 — GET /api/v1/hq/store-support/unread-signal)

// generated `schemas/hqStoreSupportUnreadSignalResponse.ts`
HqStoreSupportUnreadSignalResponse {
  openOrInProgressCount: number;         // 산하 매장 미해결(OPEN+IN_PROGRESS) 수 — 미해결 count 배지
  latestStoreActivityAt?: string | null; // 최신 점장 활동(신규 티켓·신규 답글) 시각 — 새 활동 dot
}

FE HQSidebar /admin/store-support(매장 문의) 항목: openOrInProgressCount count 배지(항상 노출) + latestStoreActivityAt 을 localStorage lastSeen(lm.support.lastSeen.hq-store.<hqId>)과 비교한 새 활동 dot. 60초 폴링(refetchIntervalInBackground:false — 탭 비활성 중단·focus 복귀 시 즉시). 목록 mount 시 lastSeen=now(markSupportSeen("hq-store", hqId)).

HQ 하위 매장 CS enums (#173)

enum비고
HqStoreTicketListItemStatus (= detail · statusChange req/resp)OPEN · IN_PROGRESS · RESOLVED · CLOSED표준 전이 전체(본사=처리 주체)
HqStoreTicketListItemPriority (= detail)URGENT · HIGH · NORMAL · LOW조회만(변경은 운영사 전담)
HqStoreTicketListItemCategory (= detail)PLAYBACK · BROADCAST · BILLING · ACCOUNT · OTHER · null타입에 | null 포함 — FE Record 키는 NonNullable 파생
HqStoreTicketCommentItemAuthorRoleSTORE_MANAGER · OPERATOR · HQ_MANAGER3-role(점장 발신·운영사·본사). INTERNAL 없음
ListHqStoreTicketsStatus/Priority/Category동값query 파라미터 enum

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 만
  attachments: TicketAttachmentItem[];  // 운영자 INTERNAL 첨부 제외 · visibility 필드 없음
}

본사 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 후속).
  playAt?: string | null;             // (SPEC #178) 절대 재생 시각(ISO-8601) — 전 기기가 이 시각에 동시에 재생 시작. null 이면 종전대로 받는 즉시 재생.
}
 
// query (generated `listStorePendingAnnouncementsParams.ts`)
ListStorePendingAnnouncementsParams {
  deviceId?: string;                  // (SPEC #178) 이 요청을 보내는 기기 id. 주면 "이 기기가 아직 재생하지 않은 방송"까지 포함(전 기기 재생 보장). 생략 = 종전 매장 단위
}

playAt (SPEC #178) — 예약 방송·본사 즉시방송은 매장의 모든 기기에서 동시에 나가야 하는데, 각 기기가 20초 폴링으로 독립 수신해 최대 20초까지 어긋났다(스피커가 분리된 매장에서 에코처럼 겹친다). 서버가 절대 시각을 지정하고 각 기기가 큐 응답 serverNowIso 로 계산한 서버-클라 오프셋을 보정해 그 시각에 시작한다(목표 시각 전 오디오 프리페치 — 기대 오차 수십 ms). 현재 값을 채우는 경로는 본사 단건 송출(dispatchHqTtsAnnouncement) 하나다 — 즉시 송출 = 발행 + 2초(IMMEDIATE_PLAY_LEAD — 전 기기가 통지받고 오디오를 준비할 여유) · 예약 송출 = scheduledAt 그대로. 본사 반복 예약 전개 (OccurrenceMaterializer)와 점장 송출(즉시·예약·[즉시 방송])은 play_at 을 채우지 않아 null 이며 받는 즉시 재생한다(점장 즉시방송은 누른 기기 1대만이라 애초에 대상이 아니다 — D8). 과거 시각이면 즉시 재생한다. ⚠️ 현재 FE 는 playAt 을 소비하지 않는다(후속 슬라이스) — 도착 즉시 재생하는 종전 동작 그대로다.

deviceId 기기 인지 조회 (SPEC #178) — dispatch 는 매장 단위 1 row 이고 기기는 20초 주기로 폴링한다. 기기 A 가 먼저 폴링해 10~30초짜리 멘트를 다 틀고 ack 하면 dispatch 가 그 자리에서 PLAYED 로 종착해, 20초 뒤 폴링한 기기 B·C 는 status=PENDING 조건에 걸려 그 방송을 영영 못 봤다. deviceId 를 주면 PENDING 에 더해 최근 5분(DEVICE_FANOUT_GRACE) 안에 PLAYED 된 방송까지 보되 이 기기가 이미 ack 한 방송은 제외한다. 생략하면 응답이 한 바이트도 달라지지 않는다(D12 하위호환).

operationId listStorePendingAnnouncements(200). 본인 매장의 PENDING 송출만(created_at ASC). apps/space /store player 가 20초 폴링해 도착 즉시 오버레이로 동시 재생(음악은 더킹). ack 은 ackStoreAnnouncement(204·멱등) — 선택 본문 AckAnnouncementRequest{ outcome? } 로 재생 결과를 보고한다(아래). ⚠️ FAILED 를 보고한 dispatchId 는 서버 재시도 정책에 따라 이 pending 목록에 다시 나타날 수 있다(임계치 미만이면 PENDING 유지). player 가 실패한 id 를 로컬 영구 블랙리스트로 막으면 그 재전달이 통째로 무력화되므로, 영구 차단 대신 짧은 쿨다운만 건다(감사 #11 — finishedAnnouncementsRef 는 종착(PLAYED·SKIPPED·revoke)에만 쓴다). revokedDispatchIds(SPEC #077 확장)는 본사 원격 즉시중단 (revoke) 신호로, player 가 재생 중 멘트 즉시 중단·재선출 차단에 쓴다(ack 안 함, Store Player). DispatchStatus enum(enums).

AckAnnouncementRequest (감사 #11 — POST /api/v1/store/announcements/{dispatchId}/ack 선택 본문)

// generated `schemas/ackAnnouncementRequest.ts`·`ackAnnouncementRequestOutcome.ts`
AckAnnouncementRequest {
  outcome?: AckAnnouncementRequestOutcome | null;  // "PLAYED" | "FAILED" | "SKIPPED". 생략(또는 본문 자체 생략) = PLAYED(하위 호환)
  deviceId?: string | null;                        // (SPEC #178) 이 재생을 수행한 기기 id. 주면 결과가 dispatch_device_ack 에 기기별로 기록. 생략 = 종전 매장 단위 ack
  signalSource?: AckAnnouncementRequestSignalSource | null;  // (SPEC #180 계측) "SSE" | "POLLING" — 그 송출을 알게 된 채널. 생략 = 미보고(NULL). 알 수 없는 값도 400 이 아니라 미보고로 흡수
  receivedAt?: string | null;                      // (SPEC #180 계측) 신호를 받은 시각(ISO-8601, 서버 시각 보정값). 서버가 signal_latency_ms 계산에 사용
  startedAt?: string | null;                       // (SPEC #180 계측) 실제 재생을 시작한 시각(ISO-8601, 서버 시각 보정값). playAt 대비 편차 실측용
}

deviceId 기기별 ack (SPEC #178) — ack 은 원자 조건부 UPDATE(WHERE status='PENDING')라 한 매장의 여러 기기가 같은 방송을 재생하면 첫 기기만 204 를 받고 나머지는 404 였다(실제로는 다 재생했는데 이력엔 1건, 어느 기기가 실패했는지도 알 수 없었다). deviceId 를 주면 결과가 dispatch_device_ack (PK(dispatch_id, device_id))에 기기별로 남고, dispatch 자체는 매장 단위로 유지된다(본사 송출 이력 UI 변경 최소화). 종착 정책:

상황dispatch 처리
한 대라도 PLAYEDdispatch PLAYED — 그 방송은 매장에 들린 것이다. 이미 다른 기기가 PLAYED 로 바꿔놔 affected=0 이어도 404 로 만들지 않는다(이 기기의 재생은 실제로 성공했고, 404 면 FE 가 실패로 오인해 재시도·오보고한다)
활성 기기 전부가 FAILED/SKIPPED 보고(성공 0)기존 실패 경로 — FAILED 는 누적 후 임계치에서 MISSED(PLAYBACK_FAILED) · SKIPPED 는 MISSED(SKIPPED)
아무도 ack 하지 않음기존 도래+grace(10분) 만료 cron 이 MISSED 로 종결(변경 없음)

꺼져 있는 기기 한 대 때문에 방송이 “미도달” 로 잡히지 않는다. 타 매장·회수된 기기 id 는 매장 단위 ack 로 처리한다.

점장 player 가 재생 결과를 보고하는 유일한 채널. 종전에는 결과와 무관하게 PLAYED 로 확정해 재생되지 않은 방송(긴급 포함)이 본사 리포트에 “정상 송출” 로 잡혔다(감사 #11).

outcomeplayer 경로서버 처리
PLAYED오버레이 onEndedPENDING→PLAYED · played_at 기록
SKIPPED시작 시 backlog drain(재생 시도 없이 폐기)즉시 PENDING→MISSED(missedReason=SKIPPED)
FAILED오버레이 onError · 워치독 강제 종료playback_failure_count +1 · last_failed_at 갱신. 3회 미만이면 PENDING 유지 → 다음 폴링에 재전달 · 3회째에 PENDING→MISSED(missedReason=PLAYBACK_FAILED)

재시도 정책은 서버 소유 — FE 는 자체 재시도 카운터를 두지 않고 실패할 때마다 그대로 FAILED 를 보고한다. 응답은 종착이든 재전달 대기든 동일한 204 라 클라이언트가 상태를 판단할 수단도 없다 (다음 폴링에 다시 내려오면 재생하면 된다). 무한 재전달은 도래+grace(10분) 자동 만료가 상한을 잡는다. 종착 이후의 재전송은 outcome 과 무관하게 404 DISPATCH_NOT_FOUND먼저 기록된 결론을 덮어쓰지 않는다. 긴급 안내방송은 backlog drain 대상에서 제외되어 SKIPPED 로 폐기되지 않는다(시작 직후 재생 — 감사 #11).

계측 3필드 (SPEC #180 D10)signalSource·receivedAt·startedAt순수 계측이며 전부 선택이다. 서버는 ack 커밋 이후 별도 트랜잭션에서 announcement_dispatchsignal_source·signal_received_at· playback_started_at 에 원값을 저장하고 signal_latency_ms = signal_received_at − COALESCE(scheduled_at, created_at) 를 확정 계산한다(즉시 송출은 row 생성이 곧 발행, 예약은 도래 시각이 발행). 클라 시각이 30초 skew·1시간 상한을 벗어나면 지연만 NULL 로 두고 원값은 보존한다. 알 수 없는 enum·형식 오류·파싱 실패는 400 이 아니라 미보고(NULL) 로 흡수한다 — 계측이 재생 결과 보고를 실패시키면 안 되기 때문이다(같은 이유로 계측 UPDATE 가 실패해도 ack 는 이미 204 로 커밋돼 있다). FE 는 signalSource(a) SSE 신호 직후 5초 창 안이고 (b) 그 응답이 신호보다 늦게 완료됐으며 (c) 그 응답에서 처음 관측된 송출에만 SSE 로 귀속한다(겹친 폴링 tick 이 실어 온 항목의 오라벨 차단). 시각은 큐 응답 serverNowIso 앵커로 보정해 보내며(SSE 귀속분의 receivedAt신호 시점의 서버 시각으로 역산), 앵커 미동기·backlog drain(SKIPPED) 이면 시각을 싣지 않고 dispatch 당 1회만 보고한다(재전달 ack 의 중복 집계 방지) (Store Player §실시간 통지). 생략하면 종전과 완전히 동일하며 백필도 없다 — NULL 은 “폴링으로 받음”·“지연 0” 이 아니라 “보고하지 않음” 이다.

StreamTicketResponse / StreamStoreAnnouncementsParams (SPEC #180 — 실시간 통지 스트림 티켓)

// generated `schemas/streamTicketResponse.ts`·`streamStoreAnnouncementsParams.ts`
StreamTicketResponse {                 // POST /api/v1/store/announcements/stream-ticket (200)
  ticket: string;                      // 서명된 단기 스트림 티켓(JWT). streamUrl 에 이미 포함 — 직접 조립이 필요한 경우를 위한 원값
  streamUrl: string;                   // 구독할 SSE 스트림의 **완전한 절대 URL**(티켓 포함). 그대로 EventSource 에 넘긴다
  expiresInSeconds: number;            // 티켓 수명(초, 기본 60). 만료 티켓으로는 연결 불가 — **재연결마다 새로 발급**
}
 
StreamStoreAnnouncementsParams {       // GET /api/v1/store/announcements/stream
  ticket?: string;                     // 스트림 티켓. **미지정 시 401**(400 이 아니다 — 스트림 응답에 에러 envelope 를 실을 수 없어 상태코드가 계약)
}

streamUrl 을 서버가 내려주는 이유 — FE 가 백엔드 base URL 을 수기로 조립하지 않게 하기 위함이다 (BFF non-generated 경로 수기 조립은 이 레포에서 프로덕션 사고를 냈다). apps/space player 는 new EventSource(streamUrl)브라우저 → 백엔드 직접 연결한다 — BFF 스트리밍 프록시가 아니다 (Vercel maxDuration 강제 재연결·in-flight 스트림의 Provisioned Memory 상시 과금 회피). 티켓은 HS256 stateless JWT 로 UPLOAD_TICKET_SECRET 을 재사용하되 type=stream-ticket claim 으로 업로드 티켓과 상호 사용이 차단된다(새 env 0 · DB·마이그레이션 0). 티켓 수명(60초)과 커넥션 수명(30분)은 무관하다 — 티켓은 연결 시점에 1회만 검증된다. 발급 실패(429·503)·연결 실패는 전부 폴링 안전망으로 조용히 폴백한다(Store Player §실시간 통지).

PlaybackReportRequest / PlaybackLogRequest / PlaybackReportResponse (#172 FE-A — POST /api/v1/store/playback/report)

// generated `schemas/playbackReportRequest.ts`·`playbackLogRequest.ts`·`playbackReportResponse.ts`
PlaybackReportRequest {
  state: PlaybackReportRequestState;   // "PLAYING" | "PAUSED" | "SILENT". ⚠️ OFFLINE 은 보고 불가(400 PLAYBACK_INVALID_STATE) — 서버가 staleness 로 파생
  currentMusicId?: string | null;      // 현재(마지막) 재생 곡 id. 무음/일시정지엔 null. 실존 안 하면 null 정규화
  logs?: PlaybackLogRequest[] | null;  // 완료/전환된 곡 로그(선택, @maxItems 200). idle heartbeat 은 비우고 곡 전환 시에만 채운다
  deviceId?: string | null;            // (SPEC #178) 이 보고를 보내는 기기 id. 주면 상태·로그가 기기별로 기록. 생략 = 종전 매장 단위
}
 
PlaybackLogRequest {
  musicId: string;                     // 재생한 곡 id
  libraryId?: string | null;           // 재생 컨텍스트 라이브러리 id(선택). 실존 안 하면 null 정규화
  startedAt: string;                   // 재생 시작 시각(ISO-8601). 멱등 키의 일부
  playedMs: number;                    // 실제 재생 밀리초(부분 재생·스킵 포함). 0..86400000
  cacheHit?: boolean | null;           // (SPEC #179 D10) 이 곡을 로컬 캐시(Cache Storage)에서 재생했는지. 생략(구버전) = null 저장 — 적중률 모수에서 제외(false 로 접지 않음)
}
 
PlaybackReportResponse {
  recordedLogCount: number;            // 이번 호출 신규 적재 로그 수(멱등 skip·미등록 곡 skip 제외). heartbeat 은 항상 반영
}

operationId reportStorePlayback(200). 본사 재생 가시성(FE-B)의 적재 채널. 매 호출이 store_playback_status(매장당 1행) upsert(state·currentMusicId·last_heartbeat_at=now). logs 가 있으면 각 곡의 music.musicSource(TRUST/AI)로 is_trust 를 파생해 play_log(전량·신탁 신고 근거) insert + playback_daily_rollup(매장×일 KST, 신탁/비신탁 분리) 가산. 멱등 — 같은 (store, musicId, startedAt) 로그는 재전송이어도 1회만 적재(중복 skip·롤업 이중 가산 없음). 미등록 곡 id 로그는 무시하고 나머지 계속 적재(부분 성공). storeId·hqId 토큰 주체 도출. claim↔DB 재검증 403 · 미인증 401. generated 훅 useReportStorePlayback.

apps/space /store player useStorePlaybackReport(apps/space/src/app/store/use-store-playback-report.ts) 가 소비 — fire-and-forget side-channel 로 오디오 파이프라인(<audio>·크로스페이드·더킹·워치독)을 await·차단·throw 로 방해하지 않는다(report 실패·네트워크 오류를 전부 삼켜 매장 음악 끊김 방지 — 핵심 불변식, 테스트로 명시 검증). 보고 트리거: (a) 곡 전환/종료 — 완료 곡 로그를 실어 report + idle 타이머 리셋(정상 재생 중엔 이게 liveness 를 겸해 idle report 가 거의 안 뜸), (b) 상태 전환(PLAYING↔PAUSED↔ SILENT) 즉시 report(대시보드 즉응), (c) idle 5분 타이머 — 곡 안 바뀐 채 마지막 신호 이후 5분이면 state-only report(setTimeout 기반이라 탭 hidden 백그라운드에서도 발화). 네트워크 실패 시 그 report 의 logs 를 로컬 큐 앞으로 되돌려 다음 report 에 합류(큐 상한 200 — 오래된 것부터 버림). started(사용자 제스처 재생 시작) 전엔 보고하지 않음 — 시작 안 한 무인 단말은 신호가 없어 서버가 OFFLINE 으로 파생한다.

deviceId 기기별 기록 (SPEC #178) — 종전엔 store_playback_status PK 가 store_id마지막에 보고한 기기가 앞 기기를 덮어썼고, play_log 는 기기 수만큼 부풀면서 어느 기기 것인지 구분되지 않아 신탁 신고 근거가 왜곡됐다. deviceId 를 주면 상태가 store_device_playback_status(기기당 1행)에, 로그가 play_log.device_id 에 기기별로 적재된다(조회는 기기 행 우선·없으면 매장 행 병합). play_log 멱등키는 partial unique 두 개로 나뉜다 — WHERE device_id IS NULL 은 종전 (store_id, music_id, started_at) 그대로(구버전 보고 보호), WHERE device_id IS NOT NULL(device_id, music_id, started_at)(두 기기가 같은 곡을 같은 밀리초에 시작해도 서로 다른 재생이라 둘 다 남는다). 타 매장·회수된 기기 id 는 무시하고 매장 단위로 처리한다(보고 실패로 재생을 방해하지 않는다).

⚠️ 두 partial unique 는 완전히 disjoint 라 이중 적재 가드가 양방향으로 필요하다. 한쪽(구버전 row 가 먼저 있고 기기 보고가 뒤늦게)은 insertIfAbsentForDeviceNOT EXISTS … device_id IS NULL 가드가 막았는데, 반대 방향에도 실제 경로가 있다: 기기 D 가 보고한 뒤 응답이 유실돼 FE 로컬 큐에 남고, 그사이 기기 D 가 회수되면 재전송 시 서버가 무효 deviceId 를 null 로 접어 매장 단위 경로로 들어가 **row 2건

  • playback_daily_rollup 2배 가산**이 된다(신탁 신고·월 청구 근거라 정확도가 곧 계약 리스크). 이제 insertIfAbsentWithoutDevice 에 같은 (store, music, startedAt) 의 기기 row 존재를 보는 대칭 가드가 있고, V65 보조 인덱스 idx_play_log_store_music_started_device 가 그 조건을 커버한다(없으면 매 재생 보고마다 seq scan — uq_play_log_device_music_started 는 선행 컬럼이 device_id 라 커버 불가). 매장 전체가 아니라 같은 키만 보므로 다른 구버전 PC 의 정상 동시 재생은 삼키지 않는다.

cacheHit 캐시 적중 계측 (SPEC #179 D10) — 음원 전송 캐시(Store Player 캐시 계층)의 적중률(원가 모델 M2)을 추정이 아닌 실측으로 얻기 위한 옵션 필드. play_log.cache_hit BOOLEAN NULL(V67)에 그대로 저장되며 롤업 미반영 — 주간 적중률은 로그 원본 SQL 로만 뽑는다. NULL (구버전 미전송)은 모수에서 제외해 계측 도입 이전 로그를 “미적중”으로 오염시키지 않는다. FE 는 곡 시작 시점의 슬롯 확정값(objectURL 재생 여부)을 고정해 보고한다 — 프리페치가 이번에 네트워크로 받아 재생한 곡은 false(네트워크 비용이 실제로 발생), 이전 사이클 적재분에서 재생한 곡만 true.

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·#178)
  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 }`.
  deviceId?: string | null;                // (SPEC #178) 이 방송을 내보내는 기기 id(`POST /store/devices` 응답의 id). **즉시 송출에만 유효** — 본인 매장의 살아있는 기기면 announcement_dispatch.target_device_id(V63)에 박혀 **그 기기 1대만** pending 으로 받는다("점장 즉시방송 = 누른 그 PC 에서만"). 생략하면 매장 전 기기 대상이라 다른 PC 들도 폴링으로 같은 방송을 받아 최대 20초씩 어긋나 반복 재생된다(구버전 하위호환은 그대로). **예약 송출(scheduledAt non-null)에는 무시**(예약은 정의상 전 기기 동시 재생). 타 매장·회수된 기기 id 는 400 이 아니라 전 기기 폴백. FE 는 `!isScheduled && deviceId` 일 때만 싣는다.
}
 
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)이 잡아 음악 위에 동시재생(더킹 + 오버레이 · SPEC #141)→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.

SlotOccupancyResponse / GetStoreSlotOccupancyParams (SPEC #109 — /api/v1/store/slot-occupancy)

점장 예약 그리드가 송출 전에 본사 점유 슬롯을 비활성 표시하기 위한 사전 조회 응답. 요청 date 는 KST 하루로 해석되고(자정~다음날 자정 [from, to)), 응답 occupiedSlots 는 그 창에서 본사(is_hq_origin=true)가 예약(SCHEDULED) 한 슬롯 시작 시각을 5분 경계로 floor 한 UTC ISO-8601 배열이다(slot_start ASC). 점장 본인 예약은 포함하지 않는다 — 점장끼리의 동일 시각 중복은 허용(SPEC #109 D4)이라 본인 예약 표시는 listStoreScheduledBroadcasts 가 담당한다.

// generated `schemas/getStoreSlotOccupancyParams.ts` · `slotOccupancyResponse.ts`
GetStoreSlotOccupancyParams {              // GET /api/v1/store/slot-occupancy query
  date: string;                            // 필수 — 조회 대상 날짜 (KST, YYYY-MM-DD)
}
 
SlotOccupancyResponse {                    // GET 200
  date: string;                            // 요청 echo (KST YYYY-MM-DD)
  occupiedSlots: string[];                 // 본사 점유 5분 슬롯 시작 시각 (UTC ISO-8601, slot_start ASC). 없으면 []
}

operationId getStoreSlotOccupancy(200). STORE_MANAGER-only · 미인증 401 · 비활성/role 불일치 403 PRINCIPAL_SCOPE_MISMATCH. generated 훅 useGetStoreSlotOccupancy({ date })·getGetStoreSlotOccupancyQueryKey. apps/space 즉시방송 모달 §예약 송출의 5분 슬롯 그리드가 scheduleMode === "SCHEDULED" 일 때 선택 날짜로 호출하고, UTC ISO 를 KST HH:mm 으로 변환해 셀 키에 매핑한다(날짜를 바꾸면 queryKey 가 바뀌어 자동 refetch). 조회 이후 본사가 새로 예약한 race 는 send 409 DISPATCH_SLOT_OCCUPIED 로만 알 수 있어, FE 가 사전 조회 ∪ 409 사후 보정으로 합쳐 표시한다(감사 #9).

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 (11종)
  targetType: StoreAuditListItemTargetType;     // 아래 enum
  targetId?: string | null;
  targetLabel?: string | null;
  detail?: string | null;
}

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

"STORE_TICKET_CREATED"            // 티켓 생성
"STORE_TICKET_COMMENT_ADDED"      // 티켓 댓글
"STORE_TICKET_CLOSED"             // 티켓 확인 종료 (#121)
"STORE_PROFILE_UPDATED"           // 프로필 수정
"STORE_PASSWORD_CHANGED"          // 비밀번호 변경
"STORE_DISPATCH_CANCELED"         // 송출 취소
"STORE_DISPATCH_BROADCAST_NOW"    // 즉시 방송 (#142 — 예약을 앞당겨 1회 추가 송출)
"STORE_DEVICE_RENAMED"            // 기기 이름 변경 (#178)
"STORE_DEVICE_REVOKED"            // 기기 정리(회수) (#178)
"STORE_DEVICE_PLAYLIST_CHANGED"   // 기기별 재생목록 변경 (#178)
"STORE_ACTIVE_PLAYLIST_CHANGED"   // 활성 PL 변경 (#129 — 선택·해제)

FE 톤: 티켓 생성/댓글·프로필 수정·활성 PL 변경·즉시 방송·기기 이름 변경·기기 PL 변경 = info(정상 활동), 티켓 종료 = success, 비밀번호 변경·송출 취소·기기 정리 = warn(보안 민감·부정 종착·되돌릴 수 없음). 색만으로 구분하지 않고 한글 라벨 병기(기기 이름 변경·기기 정리·기기 PL 변경). 기기 등록은 audit 하지 않는다 — player 마운트마다 일어나는 멱등 동작이라 로그가 그것만으로 찬다.

StoreAuditListItemTargetType (#115 · #129 · #178)

"DISPATCH"          // 송출
"PLAYLIST"          // 플레이리스트 (#129 — 활성 PL 변경 대상)
"STORE_DEVICE"      // 매장 기기 (#178 — 이름 변경·회수·기기 PL 변경 대상, target_id = store_device.id)
"STORE_MANAGER"     // 점장 계정
"SUPPORT_TICKET"    // CS 티켓

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[];   // 채팅형 스레드
  attachments: TicketAttachmentAdminItem[];  // 운영자 전용 item — INTERNAL 댓글 첨부 포함 + visibility 로 구분
}

TicketAttachmentItem / TicketAttachmentAdminItem / TicketAttachmentUploadResponse (BE PR #324·#325)

// generated `schemas/ticketAttachmentItem.ts` — 비운영자 3 채널(본사·하위 매장·점장)
TicketAttachmentItem {
  attId: string;               // 첨부 id
  commentId?: string | null;   // 귀속 댓글 id(같은 응답 comments[] 안) · 티켓 본문 첨부면 null
  fileName: string;            // 원본 파일명
  contentType: string;         // image/png | image/jpeg | image/gif | image/webp | application/pdf
  sizeBytes: number;           // 파일 크기(바이트) — FE 는 "1.4 MB" 등 사람이 읽는 단위로 표기
  createdAt: string;           // ISO-8601(FE 는 KST 표기)
  downloadUrl: string;         // 채널별 다운로드 프록시 경로 — **public blob URL 이 아니라 인증 필요 경로**
}
 
// generated `schemas/ticketAttachmentAdminItem.ts` — **운영자 채널 전용**(= 위 필드 + visibility)
TicketAttachmentAdminItem {
  ...TicketAttachmentItem 의 모든 필드;
  visibility: "SHARED" | "INTERNAL";  // 서버 판정(본문·REPLY 댓글=SHARED / INTERNAL 댓글=INTERNAL)
}
 
TicketAttachmentUploadResponse { attachment: TicketAttachmentAdminItem }  // 운영자 업로드 201

4 채널 상세 응답이 모두 attachments(없으면 [], 생성순 오름차순)를 갖는다 — 운영자 (TicketDetail)는 TicketAttachmentAdminItem[], 비운영자(HqTicketDetailResponse· HqStoreTicketDetailResponse·StoreTicketDetailResponse)는 TicketAttachmentItem[].

visibility 가 비운영자 채널에 없는 것은 의도다 — 값이 항상 SHARED 일 뿐 아니라, 필드의 존재 자체가 “INTERNAL 이라는 상태가 있다”를 알려 존재 은닉을 깬다. FE 공유 컴포넌트는 이 필드를 옵셔널로 받아 있을 때만(=운영자 화면) 항목 배지를 그린다 — undefinedSHARED 로 취급하지 않는다. commentId 는 “어느 댓글에 붙었나”(표시 위치)이고 visibility 는 “누가 볼 수 있나”라 역할이 다르다. 업로드 응답에는 댓글 스레드가 함께 오지 않으므로 commentId 만으로는 INTERNAL 여부를 되짚을 수 없다.

downloadUrl 은 상대 경로라 FE 가 BFF prefix(/api/backend)를 붙여 같은 출처 <a download> 로 받는다(쿠키 자동 · 네비게이션 없음 — 점장 player 모달에서 재생 무중단). 공유 FE 본체는 @linkmusic/ui TicketAttachments(+ mapTicketAttachmentError·validateTicketAttachmentFile· ticketAttachmentHref) 하나이며 4 채널이 업로드 채널·downloadUrl 만 주입한다. 업로드 전송은 @linkmusic/api-client useUploadTicketAttachmentFile(channel) 하나로 통일되고 4MB 초과면 업로드 티켓(백엔드 직접), 이하면 채널 multipart 로 경로가 갈린다(201 응답 계약이 같아 파싱 분기 없음).

TicketCommentItem

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

TicketCreateRequest / TicketCreateResponse

// 요청 — category? 옵셔널(#167). 미지정=미분류(운영자 내부 티켓 허용). 점장 create 는 category 필수.
{ title: string; body: string; priority: TicketPriority; hqId?: string; storeId?: string; category?: TicketCategory | null }
// 응답
{ id: string }

요청 enum TicketCreateRequestCategory(값은 TicketCategory 와 동일 · nullable). FE 생성 다이얼로그는 “미분류”(빈 값) 기본 + 5종 select — 미선택 시 category 를 payload 에서 생략한다.

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 와 동일). 변경 이력 미기록(상태/담당자 변경과 동일 관례).

TicketCategoryChangeRequest / TicketCategoryChangeResponse (#167)

// 요청 — category=null 이면 미분류로 해제. 상태머신 없어 모든 값으로 자유 변경(전이 제약·409 없음).
// 동일값 재설정 멱등 200(updatedAt 갱신). 미존재 시 404 TICKET_NOT_FOUND.
{ category?: TicketCategory | null }
// 응답
{ category?: TicketCategory | null }

changeTicketCategory(PATCH /api/v1/admin/tickets/{id}/category, OPERATOR-only). FE 훅 useChangeTicketCategory, 요청 enum TicketCategoryChangeRequestCategory(값은 TicketCategory 와 동일 · nullable). audit TICKET_CATEGORY_CHANGED. FE 상세(ticket-detail-client)는 “미분류”(빈 값=null) 포함 select 로 재분류 — 성공 시 상세 invalidate(router.refresh).

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 = "DEVICE" | "ACTIVE" | "DEFAULT" | "NONE";
// DEVICE  — (SPEC #178) 기기별 지정 PL(store_device_playlist)로 큐 생성. deviceId 를 함께 보낸 요청에서만 나온다
// 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 일 때 “기본 재생목록 재생 중”을 안내한다.

해석 우선순위 (SPEC #178)GET /api/v1/store/queue?deviceId= 로 기기를 밝히면 기존 2단 폴백 위에 1단이 얹혀 기기 지정 PL(DEVICE) → 매장 활성 PL(ACTIVE) → 본사 기본 PL(DEFAULT) → 없음(NONE) 4단이 된다. deviceId 를 생략하면(구버전 클라이언트) 이 단계를 통째로 건너뛰어 종전 2단 폴백 그대로 동작하므로 DEVICE 를 받지 않는다(D12). 기기가 타 매장이거나 회수됐으면 무시하고 폴백한다 — 재생은 어떤 경우에도 멈추지 않아야 한다.

StorePlaybackQueueResponse.reason · total · truncated (BE #284)

reason큐가 빈 이유만 담는다(곡이 있으면 null). generated 타입은 string | null 이므로 FE 는 아래 3종을 분기하고 나머지는 “본사 준비 중” 기본 분기로 흘린다.

reason의미점장 player 표시
NO_ACTIVE_PLAYLIST매장 활성 PL 도 본사 기본 PL 도 없음(source=NONE)“본사가 플레이리스트를 준비 중입니다”
EMPTY_PLAYLISTPL 은 적용됐는데 담긴 곡이 0”재생할 곡이 없습니다” (곡 추가 안내)
UNPLAYABLE_SOURCESPL 에 곡은 있는데 전부 재생 불가한 음원 URL(http(s) 아닌 local:// 등)이라 서버가 전부 제외”플레이리스트의 음원 파일을 재생할 수 없습니다” + 플레이리스트명 + 고객지원 문의 유도

UNPLAYABLE_SOURCESEMPTY_PLAYLIST 로 뭉개면 점장에게 거짓 안내(“곡을 추가하면 재생됩니다”)가 나가고, 실제 원인(음원 파일·스토리지)은 매장에서 손댈 수 없다. 그래서 별도 코드로 분리해 운영자 확인 경로(문의)로 보낸다 — 상세는 Store Player §source/빈 상태.

  • total = 상한(500)·재생 불가 필터를 모두 적용한 뒤의 곡 수(= items 크기).
  • truncated = 필터 이전 후보 수가 상한을 넘었는지. 따라서 total < 500 이면서 truncated=true 인 조합은 버그가 아니라 정상이다(PL 규모는 큰데 재생 가능한 곡이 적다는 신호).

StorePlaybackQueueResponse — 시간대 슬롯 확장 (SPEC #171)

GET /api/v1/store/queue 응답을 시간대별 라이브러리 스케줄로 확장했다. 현재 슬롯과 다음 슬롯 lookahead·경계·서버 시각을 담고, 최상위 items/total/truncated하위호환 미러(=currentSlot 의 것)로 유지한다(기존 player 회귀 안전망).

interface StorePlaybackQueueResponse {
  active: boolean;
  source: "DEVICE" | "ACTIVE" | "DEFAULT" | "NONE";  // DEVICE 는 deviceId 를 보낸 요청에서만(SPEC #178)
  playlistId?: string | null;
  playlistName?: string | null;
  reason?: string | null;
  currentSlot: QueueSlot;           // 현재 시간대 슬롯(폴백=전체 셔플이면 libraryId 등 null, items 는 채워짐)
  nextSlot?: QueueSlot;             // 다음 슬롯 lookahead(크로스페이드/전환용). 폴백·하루 끝이면 null
  nextBoundaryMinute?: number | null; // 현재→다음 슬롯 경계(KST 분, [0,1440]). 경계 없음이면 null. ⚠️ 1440="다음날 0:00"(자정)
  serverNowIso: string;             // 서버 현재 시각(ISO-8601, KST 오프셋) — FE 시계 오프셋(드리프트 무력화)
  // 하위호환 미러 — currentSlot 의 것
  items: QueueItem[];
  total: number;
  truncated: boolean;
}
 
type QueueSlot = {
  libraryId?: string | null;   // 슬롯 라이브러리 id (전체 셔플 폴백이면 null)
  libraryName?: string | null;
  startMinute?: number | null; // 슬롯 시작 (KST 분, 30분 배수·포함). 폴백이면 null
  endMinute?: number | null;   // 슬롯 끝 (KST 분, 30분 배수·미포함). 폴백이면 null
  items: QueueItem[];          // 비셔플 재생 큐 항목 (앱이 로컬 셔플·반복)
  total: number;
  truncated: boolean;
} | null;
 
// query (generated `getStorePlaybackQueueParams.ts`)
type GetStorePlaybackQueueParams = {
  deviceId?: string;           // (SPEC #178) 이 요청을 보내는 기기 id. 주면 기기 지정 PL 이 최우선(source=DEVICE). 생략 = 종전 매장 단위
};
  • currentSlot.items 는 비셔플이며 앱이 로컬 셔플한다(종전엔 서버가 셔플했다). 앱은 슬롯을 손에 쥐고 소진 시 재셔플·반복하며, 서버를 다시 때리지 않는다(BE 는 요청마다 라이브러리 전곡을 로드·셔플).
  • 경계 판정(FE): nextBoundaryMinute 을 절대 epoch(ms)로 환산하고(KST 자정 epoch + 분*60000), serverNowIso 기반 서버시각 오프셋으로 현재시각을 계산해 비교한다. 1440 은 자정으로 day-wrap 된다(nowMin >= 1440 판정은 자정 이후 wrap 으로 영영 전환하지 못하므로 금지). BE 는 nextSlot 에 이미 wrap 을 적용해 자정 다음 슬롯을 담아 보내며, nextBoundaryMinute 값 자체는 1440 을 유지한다.
  • 소비 상세는 Store Player §시간대 슬롯 전환 참조.

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

음악 2계층(음원→라이브러리→플레이리스트)의 매장 적용 층(StoreActivePlaylist). 매장당 단일 활성·공유 모델(PL 참조, 복사본 아님).

⚠️ #170 — 운영사 진입점 orphan. 운영사(OPERATOR) 매장별 활성 PL 지정(getStoreActivePlaylist· setStoreActivePlaylist·clearStoreActivePlaylist = /api/v1/admin/stores/{id}/active-playlist)은 UI 가 제거돼 호출부 없음(BE 후속 cleanup). 활성 PL 을 실제로 정하는 경로는 점장 self (setStoreOwnActivePlaylist, PATCH /api/v1/store/me/active-playlist, #129) — 아래 응답 DTO (StoreActivePlaylistResponse)를 그 경로가 재사용한다.

StoreActivePlaylistResponse (getStoreActivePlaylist 200 · setStoreActivePlaylist 200)

적용 PL 요약. 미적용 시 active=false·나머지 null. appliedAt활성 PL 을 적용한 시각 전용 값(store.active_playlist_applied_at, V52) — 종전엔 store.updatedAt 근사라 매장명 변경 등 무관한 갱신에 끌려다녔다(BE #284).

{
  active: boolean;             // 활성 PL 적용 여부 (미적용 시 false)
  playlistId?: string | null;  // 적용된 PL id (미적용 시 null)
  name?: string | null;        // 적용된 PL 이름 (미적용 시 null)
  libraryCount?: number | null;// 담긴 활성 라이브러리 수 (미적용 시 null)
  appliedAt?: string | null;   // 적용 시각(store.active_playlist_applied_at V52, 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