Store Onboarding — /stores/new (2-step)
SPEC #011 정합 · SPEC #147 약관 동의 시점 이전 · SPEC #184 점장 계정 통합. v0.4.0 도입.
SPEC #147: 약관 동의 step 이 발급 마법사에서 제거됐다(3-step → 2-step). 약관 동의는 점장 본인이 계정 설정(첫 로그인) 시 진행한다. 발급 request 에서
consent가 빠졌고(LegalConsentPart삭제), 발급 page 의 활성 약관 server fetch·미게시 차단 Banner 도 제거됐다.
SPEC #184: 이메일을 한 번만 받는다. 예전에는 등록 폼의
managerEmail이Store의 연락처 컬럼일 뿐이고 로그인 ID 는 발급 다이얼로그에서 새로 타이핑했다 — 두 값이 갈려도 프리필·대조·경고가 0건이었다(감사 A-0 ①). 이제 등록 폼의 “점장 계정” 섹션에 입력한 이메일이 곧 로그인 ID 이고 매장 생성과 같은 트랜잭션에서 STORE_MANAGER 계정이 만들어진다(D1). 임시 비밀번호는 서버가 만들고 화면에 표시하지 않으므로(D6) 계정 설정 메일이 유일한 진입 경로다. 계약 시점과 점장 채용 시점이 다를 수 있어 [점장 미정으로 등록] 건너뛰기를 명시 토글로 제공한다(D2). 또 기본 소속 프리셋(INDEPENDENT)이 제거됐다(D7 — 감사 T5-1).
Overview
운영사가 매장을 등록 — 두 갈래:
- INDEPENDENT — 개인 매장. 가상 본사 산하 (
hqId = IndependentHqIds.SINGLETON).POST /api/v1/admin/stores/independent(SPEC #011 v0.6.0). - DIRECT / FRANCHISE — 가맹 본사 산하 매장.
POST /api/v1/admin/hq/{hqId}/stores(SPEC #011 v0.6.0~v0.6.1). FE/stores/new의 affiliation 라디오로 분기.
두 endpoint 모두 backend 도입 완료. plan·billingAnchorDay 는 HQ 소속 매장의 경우 HQ 에서 자동 상속.
Spec
Step 구조
| Step | 내용 |
|---|---|
| 1. 기본 정보 | 소속(명시 선택 — 프리셋 없음, D7) · 매장명 · 주소 · 점장 계정 섹션(점장 이메일 = 로그인 ID · 이름 · 전화 · [점장 미정으로 등록] 토글) · plan(AI/TRUST) · billingAnchorDay |
| 2. 확인 | 요약(점장 계정 발급 여부·로그인 ID 단정) + “약관 동의는 점장 본인이 계정 설정 시 진행” 안내 + [등록] |
| (등록 완료) | 매장 등록 완료 화면 — 계정 발급/메일 발송 결과 안내 + [설정 이메일 다시 보내기] + [매장 상세 보기](응답 storeId) / [목록으로] |
소속 프리셋 제거 (D7)
예전 initialStoreOnboardingState.affiliation 은 "INDEPENDENT" 였다. 소속을 안 건드리면
본사 산하 매장이 가상 본사로 영구 고정되고 되돌릴 endpoint 가 없다(감사 T5-1). 독립 매장은
PL 배정 수단이 제품에 0(OP-F1)이라 가장 쉽게 도달하는 영구 무음 경로였다. 이제 초기값은
""(미선택)이고, 선택 전에는 분기 섹션(요금 설정 / 본사 선택)이 렌더되지 않으며 [다음]도 비활성이다.
점장 계정 섹션 (D1/D2)
| 상태 | 동작 |
|---|---|
| 이메일 입력 (기본 경로) | 등록과 동시에 STORE_MANAGER 계정 발급 + 계정 설정 메일 발송. 그 이메일이 로그인 ID |
| [점장 미정으로 등록] 체크 | 이메일 입력란이 비워지고 잠긴다(stale 값이 계정을 만드는 사고 차단) · payload 에서 managerEmail 이 빠진다 · 매장만 등록 |
| 이메일·토글 둘 다 없음 | [다음] 비활성 — 결정을 강제한다 |
이름·전화는 여전히 선택이며 매장 연락처로 저장된다(전화는 로그인에 쓰이지 않는다). 임시 비밀번호 입력란은 없다(D6 — 서버 생성 · 미노출).
State shape
type StoreOnboardingState = {
step: 0 | 1;
affiliation: "" | "INDEPENDENT" | "HQ"; // SPEC #184 D7 — "" = 미선택(프리셋 제거)
skipManager: boolean; // SPEC #184 D2 — "점장 미정으로 등록" 토글
store: {
name: string;
address?: string;
managerName?: string;
managerEmail?: string;
managerPhone?: string;
plan: "AI" | "TRUST"; // INDEPENDENT 분기 전용 (HQ 소속은 HQ 에서 상속)
billingAnchorDay: number; // INDEPENDENT 분기 전용
};
hqAffiliation: { // affiliation === "HQ" 일 때만 사용
hqId: string;
storeType: "DIRECT" | "FRANCHISE" | "";
};
// SPEC #147 — consent state 제거. 동의는 점장 계정 설정(setup) 단계로 이전.
};SPEC #184 —
managerEmail은 더 이상 optional 이 아니다.skipManager === false(기본)면 필수이고 그 값이 점장 로그인 ID 다.skipManager === true일 때만 미입력이 허용되며 payload 에서도 제외된다.managerName·managerPhone은 여전히 선택(매장 연락처).
Endpoint
마지막 step “등록” 시 affiliation 에 따라 단일 POST 로 분기 (SPEC #147 — request 에서 consent 제거):
INDEPENDENT — POST /api/v1/admin/stores/independent (StoreOnboardingRequest):
- request:
{ store: {...plan, billingAnchorDay} } - 단일 transaction: Store INSERT (type=INDEPENDENT, hqId=SINGLETON) +
managerEmail이 있으면 OperatorAccount(STORE_MANAGER) INSERT + ACCOUNT_SETUP 토큰 발급(SPEC #184 D1). 메일 발송만 커밋 후(afterCommit) - response 200:
{ storeId, managerAccountId?, managerAccountCreated, setupEmailSent? }
HQ 소속 — POST /api/v1/admin/hq/{hqId}/stores (HqAffiliatedStoreOnboardingRequest):
- request:
{ store: {...storeType} }— plan·billingAnchorDay 없음 (HQ 상속) - response 200: 위와 동일 shape
등록 완료 화면 (SPEC #184)
예전에는 성공 시 router.replace("/") 로 대시보드에 튕기며 응답 storeId 를 버렸다(감사
OP-M16). 이제 완료 화면으로 전환해 네 경우를 갈라 안내하고 [매장 상세 보기]로 잇는다:
| 응답 | 화면 |
|---|---|
managerAccountCreated=true · setupEmailSent=true | success — “계정 발급 + 입력한 이메일 주소로 안내 메일 발송. 그 이메일이 로그인 ID” |
managerAccountCreated=true · setupEmailSent=false | danger(role="alert") — 계정은 있는데 진입 경로가 없다(임시 비밀번호 미노출). [설정 이메일 다시 보내기] 강조 |
managerAccountCreated=false | warn — “점장 미정으로 등록됨. 매장 목록의 [점장 계정: 미발급] 필터로 찾아 발급하세요” |
| 2xx 인데 본문 파싱 실패 | warn role="alert"(store-onboarding-result-unknown) — “매장은 등록됐지만 점장 계정 발급 여부를 확인하지 못했습니다. 매장 상세의 [점장 계정] 을 먼저 확인하세요”. managerAccountCreated ?? false 로 폴백해 “점장 미정” 으로 단정하면 실제로는 발급된 계정 위에 두 번째 계정을 발급하게 된다(매장당 복수 점장 허용) |
재발송은 POST /api/v1/admin/accounts/{accountId}/resend-setup(OPERATOR-only, useResendAccountSetup).
[설정 이메일 다시 보내기] 버튼은 managerAccountId 가 있을 때만 렌더되므로, danger 배너 문구도
같은 조건으로 갈라 없는 버튼을 안내하지 않는다(없으면 “매장 상세의 [점장 계정] 에서 재발송”).
약관 consent INSERT(StoreConsent·StorePrivacyConsent)는 발급이 아니라 점장 본인의 계정 설정
(setup/complete) 단계에서 수행된다(SPEC #147).
이탈 경로 (SPEC #164 C4)
마법사 footer 왼쪽에 [목록으로](onboarding-cancel) 이탈 액션이 있어 진행 중에도 매장
목록(/stores)으로 빠져나갈 수 있다. 입력이 있는 단계(isStoreOnboardingDirty —
affiliation·hqAffiliation·store 가 초기값에서 하나라도 달라짐)에서는 본사 마법사와 동일한
OnboardingLeaveConfirmDialog 로 “작성 중인 내용이 사라집니다” 확인을 먼저 받고, [나가기]
시 router.push("/stores") 한다. step0 빈 입력에서는 확인 없이 곧장 이동한다.
States & Edge Cases
| 상태 | 처리 |
|---|---|
| 필수 필드 미입력 | 다음 step disabled |
| [목록으로] · 입력 있음 (SPEC #164 C4) | 확인 다이얼로그 → [나가기] 시 /stores 이동 |
| [목록으로] · step0 빈 입력 | 확인 없이 곧장 /stores 이동 |
| 소속 미선택 (SPEC #184 D7) | [다음] 비활성 + “소속을 먼저 선택해주세요” 힌트. 분기 섹션 미렌더 |
| 점장 이메일 미입력 + [점장 미정] 미체크 (D1) | [다음] 비활성 |
| 본사 미선택/없음 (HQ 분기) | HQ_NOT_FOUND(404)·INVALID_HQ(400) → step1 inline |
이메일 중복 409 DUPLICATE_EMAIL (D9) | 매장도 생성되지 않았다. step0 으로 되돌려 이메일 필드 에러 + Banner “매장은 등록되지 않았으니 다른 이메일로 다시 등록해주세요”. “매장은 만들어졌다” 류 안내 금지 |
| 409 이후 비-409 에러(5xx 등)로 재제출 실패 | 이메일 인라인 경고를 유지한다(409 계열 응답에서만 갱신). 지워버리면 매장명만 고쳐 재제출한 사용자에게 “이메일은 이제 괜찮다” 로 읽히지만 값은 여전히 중복이다 |
| 소속 미선택 상태로 submit 도달(내부 불변식 위반) | 네트워크 실패와 구분해 step0 으로 되돌리고 “매장 소속이 선택되지 않았습니다…” 안내(회선을 의심하며 재시도만 반복하지 않게) |
429 RATE_LIMITED (STORE_PROVISION 20/분) | “요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.” |
setupEmailSent === false | 완료 화면 danger + [설정 이메일 다시 보내기] |
| 5xx | ”잠시 후 다시 시도” |
| 새로고침 | state 소실 |
활성 약관 부재·약관 version 검증은 발급이 아니라 점장 계정 설정(setup) 단계에서 다룬다(SPEC #147).
Constraints
- INDEPENDENT 분기: 가상 본사 (
SINGLETON) 가 자동 hqId — UI 가 본사 선택 안 함 - HQ 분기:
hqId+storeType(DIRECT/FRANCHISE) 선택, plan·billingAnchorDay 는 HQ 상속 (UI 미입력) - 소속은 등록 후 변경 불가 — 사후 이동 endpoint 가 없다(SPEC #184 §1-1 명시적 비포함)
managerEmail= 점장 로그인 ID(SPEC #184 D1). 기존 매장의managerEmail은 백필하지 않았다(D3) — 운영 중 매장에서는 여전히 연락처일 수 있으므로 “정합” 마이그레이션을 만들면 멀쩡한 계정의 로그인 ID 가 바뀐다(§6 ⚠️)
Roadmap
- 운영자용 점장 이메일 정정 endpoint (감사 AC-F2 잔여 — SPEC #184 §1-1 비포함)
- 사후 소속 이동 endpoint (감사 T5-1 잔여)
References
- SPEC #011 · #147 · #184
linkmusic-frontend-space/apps/admin/src/app/(protected)/stores/new/- 감사:
docs/audit/persona-flow-audit-2026-08.md부록 A(A-0 ①)·부록 B(B-3 T1-3 · B-5 T5-1)