FeaturesStore (매장)Store Customer Support (/store/support)

Store Mode — 점장 CS 티켓 (apps/space /store/support)

SPEC #112 도입(점장 CS 티켓 — 작성·조회·상세·댓글). 점장(STORE_MANAGER)이 운영사에 문의·요청· 이슈를 보고하는 채널. 본사 CS(#086)의 격리 service + view DTO 분리 패턴을 1:1 미러하되, 점장은 본인 매장(store_id) 스코프에 격리되고 작성 시 category 가 필수다.

점장 모드(apps/space)의 실 STORE_MANAGER 화면이다. 본 표면 범위는 본인 매장 티켓의 작성· 조회·상세·댓글(read+create+comment)뿐이며 상태/우선순위 변경은 운영자 전담(점장 무권한, 읽기 전용). 본사(#108)와 달리 점장은 상태/우선순위 액션을 받지 않는다(점장 IT 비숙련 — PRD Page 8).

정책 (SPEC #112)

  • 점장 → 운영사 문의: 점장 티켓은 본인 매장이 자동 지정되며(store_id 토큰 주체 도출), 운영자 백오피스(/api/v1/admin/tickets)에서 트리아지·답변한다.
  • 점장은 작성·조회·댓글 + 확인 종료(/close) — 우선순위·기타 상태 변경 권한은 없다(운영자 전담). 단 SPEC #121 로 점장에게 확인 종료(RESOLVED→CLOSED) 1종만 허용한다(아래 “확인 종료” 참조). 그 외 상세는 읽기 전용 메타 사이드만 노출(처리 액션 섹션 없음).
  • 우선순위 미노출: 작성 시 점장은 우선순위를 지정하지 않는다 — 서버가 NORMAL 고정(운영자가 트리아지). 본사 작성 폼의 URGENT 제외보다 더 보수적.
  • category 필수 (D7): TicketCategory 5종 — PLAYBACK(재생·음악)·BROADCAST(방송·안내)· BILLING(결제·정산)·ACCOUNT(계정·로그인)·OTHER(기타). 점장 작성 시 필수(@NotNull). 목록은 category 필터 지원, 상세는 category 배지 노출. category 컬럼은 공용 ticket 테이블에 nullable 로 추가(V35) — 기존 운영자/본사 티켓 호환. 카테고리 자동 선택(추론)은 후속 — v1 은 점장 수동 선택.
  • audit 누적 (SPEC #114, #112 audit tail 마감): 점장 ticket 생성댓글 추가 가 각각 store_audit_log(V36)에 1건 기록된다 — STORE_TICKET_CREATED(detail title=...·category=NAME· bodyLength=NSTORE_TICKET_COMMENT_ADDED(detail bodyLength=N), 둘 다 target_type=SUPPORT_TICKET· target_id=ticket.id(댓글도 ticket scope)·target_label=ticket.title 스냅샷. StoreTicketService 의 create/comment 끝에서 같은 트랜잭션 hook(audit INSERT 실패 시 ticket/댓글 저장도 함께 롤백 — 원자성). 운영자가 점장 모드로 위장해 작성한 경우 actor_role=OPERATOR_IMPERSONATING + 원본 운영자 스냅샷도 함께 기록. #112 가 백본 부재로 생략했던 audit 을 마감. 본문 자체는 audit 에 두지 않고 bodyLength 시그널만. v1 은 기록만(조회 view 는 후속). 상세는 Store 감사.

격리 (보안 불변식)

  • D1 과노출 차단: 점장 티켓은 store_id 만 채우고 hq_id 는 null — 본사 CS 화면(#086 findHqList = WHERE hq_id)에 점장 문의가 섞이지 않는다. 운영자 백오피스에서만 식별.
  • D2 격리 우회 차단: 목록·상세·댓글 모두 WHERE store_id = :storeId 강제. 타 매장 티켓 id 도 404 로 은닉(존재 흘림 차단).
  • D3 INTERNAL 메모 격리: 상세 댓글은 kind=REPLY 만 노출 — 운영자 INTERNAL 메모는 점장에게 보이지 않는다(본사 #086 미러).

Overview

STORE_MANAGER 가 /store/support 에 진입하면 자기 매장(storeId 토큰 주체 도출)이 작성한 CS 티켓을 시간 역순으로 본다. [문의하기]로 신규 티켓을 만들고(제목·분류·내용), 행을 클릭해 상세에서 운영사 답변 스레드를 확인·자기 댓글을 덧붙인다.

시안 출처: design_14 핸드오프(Phase 8 §8G-② store-support.jsx)로 시안 정합 완료. 골격은 본사 /admin/support(#086) idiom(공용 ListToolbar/ListPagination + client state + 채팅형 댓글 스레드)을 이어받되 정식 시각으로 교체했다 — category 큰 칩 5종(필수·?category= 사전선택), 댓글 좌우 말풍선(STORE_MANAGER 우측 primary-soft / OPERATOR 좌측 surface-2), 확인 종료(/close) 1종만·우선순위 미노출. 서브페이지 헤더는 신규 공용 atom StoreSubHeader(/support· /profile·/playlist·/scheduled 공용). 시각만 교체 — 동작·계약·라우팅·점장 가독성 보존. [[feedback_design_only_from_handoff]].

진입점

  • 점장 player 헤더(/store): 우상단 [고객지원] 링크(store-player-support-link, lucide:LifeBuoy) → /store/support. 사이드바가 없는 점장 셸이라 헤더에 둔다(프로필 옆).
  • 점장 프로필(/store/profile): “고객지원” 섹션의 [운영사에 문의하기] 카드 (store-profile-support-link) → /store/support. 두 곳에서 discoverable.

페이지 1 — 목록 (/store/support)

apps/space/src/app/store/support/page.tsx(server 셸) + store-ticket-list-client.tsx(client). 서버사이드 페이지네이션 + client-query(useListStoreTickets) — q·status·category·page 를 client state(applied**Draft)로 보유. 정렬은 서버 고정(updated_at DESC, id ASC).

  • 헤더: 제목 “고객지원” + 부제(내 문의 N건 · 운영사에 도움을 요청하세요) + 우측 [문의하기] 버튼(store-ticket-create-open, lucide:Plus, size=lg) → /store/support/new 네비.
  • 툴바: 공용 ListToolbar — 제목 검색(≤100, OpenAPI 일치) + status select + category select
    • 적용/초기화. draft↔applied 분리.
  • 표 4컬럼 (StoreTicketListItem) — 본사 5컬럼에서 우선순위 컬럼 제거(점장 미노출), category 추가:
    • 제목 (font-medium)
    • 상태 배지 (StatusPill 4종 — 텍스트 병기): OPEN=“신규”(info) · IN_PROGRESS=“처리 중”(info) · RESOLVED=“답변 완료”(success) · CLOSED=“종료”(muted). 전수 강제 Record.
    • 분류 배지 (StatusPill 5종): 재생·음악 / 방송·안내 / 결제·정산 / 계정·로그인 / 기타. category 미설정(nullable) 행은 ”—”.
    • 작성 시각 (createdAt, formatKstDateTime, 우측 정렬)
  • 행 동작: 행 클릭/Enter/Space → /store/support/{id} 상세. tabIndex=0 + aria-label + focus-visible ring.
  • 빈 상태: total=0 + 필터 없음 → “아직 문의가 없습니다.” · 필터 있음 → “조건에 맞는 문의가 없습니다.” + [필터 초기화]. total>0 인데 이 페이지 0 행 → “이 페이지에 표시할 문의가 없습니다.” + [첫 페이지로].
  • 페이지네이션: 공용 ListPaginationsize=20.
  • 에러 매핑: Banner tone="danger" (store-ticket-list-error) — 401/403/5xx/BACKEND_UNREACHABLE/그 외 4xx 분리.

페이지 2 — 작성 (/store/support/new)

apps/space/src/app/store/support/new/page.tsx(server 셸) + store-ticket-new-client.tsx(client). storeId 는 토큰 주체 도출 — 요청 본문에 직접 지정할 수 없다(타 매장 격리).

  • 폼 3 필드 (CreateStoreTicketRequest) — 본사와 달리 우선순위 없음, category 추가:
    • 제목 input — @maxLength 200 · NotBlank. 빈/blank·201자 이상 → submit disabled + 보조 텍스트 · 우측 카운터 N / 200.
    • 문의 분류 select — category 필수(미선택 placeholder “분류 선택…” + disabled option). 5종. 미선택 시 submit disabled + “문의 분류를 선택해 주세요.”.
    • 내용 textarea — @maxLength 5000 · NotBlank. 동일 검증·카운터. rows={12} resize-y. 줄바꿈/공백 보존.
  • 헤더: 제목 “문의하기” + 우측 목록으로.
  • 푸터: [취소](목록으로 push) + [문의 보내기](submit · disabled when invalid · “보내는 중…” + aria-busy, size=lg).
  • 성공 흐름: mutation 응답 201 = StoreTicketDetailResponserouter.replace(/store/support/{id}) (뒤로가기 중복 등록 차단).
  • 에러 매핑 (mapCreateError): Banner tone="danger" (store-ticket-new-error).

페이지 3 — 상세 (/store/support/[id])

apps/space/src/app/store/support/[id]/page.tsx(server 셸, Next 15 params: Promise) + store-ticket-detail-client.tsx(client). 상세 조회·댓글 추가는 generated 훅 직접 사용 (useGetStoreTicketDetail·useAddStoreTicketComment).

  • 헤더: 좌측 [목록으로] + 제목(store-ticket-detail-title, truncate) + 상태/분류 배지 + 댓글 N건.
  • 본문 섹션 (store-ticket-detail-body): pre-wrap 텍스트 카드 — BE 본문 그대로.
  • 댓글 스레드 (store-ticket-detail-comments) — 좌우 말풍선:
    • STORE_MANAGER=“내 문의”(우측 정렬 · primary-soft 말풍선)
    • OPERATOR=“운영사 답변”(좌측 정렬 · success 틴트 — 답변 도착의 긍정 신호)
    • 시간순(BE createdAt asc 보장)·REPLY 만(INTERNAL 메모 비노출). 0건이면 “아직 답변이 없습니다. 운영사가 확인 후 답변드립니다.”.
    • 추가 form (store-ticket-comment-form): textarea(≤5000, AddStoreTicketCommentRequest.body) + [댓글 보내기]. 성공 시 getGetStoreTicketDetailQueryKey(id) invalidate → 스레드 자동 갱신 + 입력 초기화. 실패 시 인라인 Banner (store-ticket-comment-error).
  • 메타 사이드(store-ticket-detail-meta, 읽기 전용): 상태·분류·작성/수정 시각(KST). 우선순위 변경 UI 없음(운영자 전담) — 본사(#108) 처리 섹션을 점장은 받지 않는다.
  • 확인 종료(SPEC #121): status=RESOLVED 일 때만 메타 사이드 하단에 [확인 종료] 액션(store-ticket-close)을 노출한다. 그 외 상태(OPEN/IN_PROGRESS/CLOSED)에서는 미노출(기존 읽기 전용 유지). 클릭 → useCloseStoreTicket(PATCH /api/v1/store/tickets/{id}/close) → 성공 시 200 read-back(status=CLOSED) + getGetStoreTicketDetailQueryKey(id) invalidate 로 상태 배지·메타 자동 갱신(버튼은 RESOLVED 가 아니게 되어 사라짐). 점장은 close 1종만 — reopen·기타 전이는 불가(본사 #108 의 부분집합). 에러 매핑: 409 TICKET_INVALID_STATUS_TRANSITION(이미 종료/비-RESOLVED race) → “이미 종료되었거나 종료할 수 없는 상태…” · 404 TICKET_NOT_FOUND(미존재·타 매장 은닉) → “이미 삭제된 문의입니다.” → 인라인 Banner(store-ticket-close-error).
  • 에러 매핑: 404 TICKET_NOT_FOUND → “문의를 찾을 수 없습니다.”(타 매장·미존재 모두 은닉). 401/403/5xx/BACKEND_UNREACHABLE/그 외 — 동일 패턴.

인가

/api/v1/store/tickets/**hasRole("STORE_MANAGER") 1차 경계 + service verifyStoreScope claim↔DB 재검증(storeId 토큰 주체 도출, 요청 파라미터 없음). 미인증 401 · role/소속 불일치 403 PRINCIPAL_SCOPE_MISMATCH. 보안 불변식: 모든 endpoint 에 WHERE ticket.store_id = :storeId 강제 — 타 매장 티켓 id 도 404 로 은닉(D2).

모든 호출은 generated apiFetch 가 BFF catch-all /api/backend/... 경유(토큰 서버 전용).

BE endpoint 5종 (#112 · #121)

  • GET /api/v1/store/tickets (listStoreTickets) — 본인 매장 티켓 목록. 필터 q/status/category/page/size.
  • POST /api/v1/store/tickets (createStoreTicket) — 작성. body { title, body, category }(category 필수). 응답 201 StoreTicketDetailResponse. 초기 status=OPEN·priority=NORMAL·hq_id=null. 같은 트랜잭션 audit STORE_TICKET_CREATED 1건(#114).
  • GET /api/v1/store/tickets/{id} (getStoreTicketDetail) — 단건 상세 + 댓글(REPLY 만). 타 매장·미존재 404.
  • POST /api/v1/store/tickets/{id}/comments (addStoreTicketComment) — 점장 댓글(REPLY) 추가. 응답 201 StoreTicketCommentItem(authorRole=STORE_MANAGER). 타 매장 404. 같은 트랜잭션 audit STORE_TICKET_COMMENT_ADDED 1건(#114).
  • PATCH /api/v1/store/tickets/{id}/close (closeStoreTicket, SPEC #121) — 점장 확인 종료(RESOLVED→CLOSED). body 없음. 응답 200 StoreTicketDetailResponse(read-back, status=CLOSED). store_id 격리 원자적 UPDATE(WHERE id AND store_id AND status='RESOLVED'). 비-RESOLVED → 409 TICKET_INVALID_STATUS_TRANSITION, 미존재·타 매장 → 404 TICKET_NOT_FOUND. 같은 트랜잭션 audit STORE_TICKET_CLOSED 1건(#114).

자세한 endpoint 규약은 Endpoints · DTO 는 DTOs 참조.

CS 새 답변 dot (SPEC #119 F5 · D2)

점장 셸은 사이드바가 없으므로, dot 은 player 헤더 [고객지원] Link(store-player-support-link)에 붙인다(Store Player §“CS 새 답변 dot” 참조). getStoreSupportUnreadSignal(StoreSupportUnreadSignalResponse.latestOperatorReplyAt)을 60초 폴링 (점장 안내방송 20초 폴링과 별개 query)하고, localStorage lastSeen(lm.support.lastSeen.store.<storeId>useGetStoreMe().id)보다 최신 운영자 REPLY 가 있으면 버튼 우상단에 primary 도트(store-player-support-dot)를 표시한다(boolean dot, D2). /store/support 목록 진입(mount) 시 lastSeen=now 로 갱신해 해소한다(D5). last-seen 헬퍼는 본사와 공유(apps/space/src/lib/support-last-seen.ts, role=store). localStorage 실패 시 보수적으로 dot 유지(D1 리스크 1). 점장 안내방송 배지는 만들지 않는다(D3 — player 가 PENDING 을 이미 자동 소비).

범위 밖 (v1 제외 — 후속)

  • 첨부파일(10MB): ticket 백본 공통 첨부 도메인 별도 SPEC(본사 #086 도 미구현).
  • category 자동 선택(추론): v1 은 점장 수동 선택. FR-6.5/7.5 자동 추론은 후속.
  • 운영자/본사 view 의 category 표면화: V35 로 컬럼은 공용 추가했으나 이번엔 점장 채널만 소비.
  • 상태/우선순위 변경: 점장 무권한(운영자 전담). 단 확인 종료(RESOLVED→CLOSED)는 SPEC #121 로 도입됨(위 참조) — reopen·우선순위·기타 전이는 여전히 제외.
  • 전용 디자인 시안 ✅ design_14 §8G-② 정합 완료(시각만 교체 — roadmap/design-debt §1 기록).