From cddf8a3c528c8552e8eee737379576925e93d029 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 6 Oct 2026 16:21:29 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20CodeRabbit=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0(=EB=A6=AC=EB=B7=B0=20=EB=B4=87=EC=9D=80=20Co?= =?UTF-8?q?dex=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 결정: CodeRabbit은 BE 전용, FE 레포(관리자·판매자)는 쓰지 않음. - .coderabbit.yaml 삭제, 봇 리뷰 문서를 Codex만으로, decisions D11 갱신·D17 추가. --- .coderabbit.yaml | 73 -------------------------- docs/guide/architecture-conventions.md | 2 +- docs/guide/decisions.md | 3 +- 3 files changed, 3 insertions(+), 75 deletions(-) delete mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index 30af30d..0000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,73 +0,0 @@ -# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json -language: 'ko-KR' - -reviews: - profile: 'chill' - request_changes_workflow: false - commit_status: true - fail_commit_status: false - high_level_summary: true - high_level_summary_in_walkthrough: false - poem: false - in_progress_fortune: false - suggested_labels: false - auto_apply_labels: false - suggested_reviewers: false - - auto_review: - enabled: true - auto_incremental_review: true - drafts: false - - path_filters: - - '!src/graphql/generated/**' - - '!src/routeTree.gen.ts' - - '!schema/schema.graphql' - - '!src/shared/ui/**' - - '!public/**' - - '!pnpm-lock.yaml' - - path_instructions: - - path: 'src/**/*.{ts,tsx}' - instructions: | - React + TanStack Router/Query SPA. docs/guide/architecture-conventions.md가 기준입니다. - - shared는 features를 import하면 안 되고, feature 간 import는 대상 index.ts로만 가능합니다. - - 쿼리 키는 feature의 키 팩토리로만 만들고, mutation 뒤 관련 키를 invalidate해야 합니다. 낙관적 업데이트는 쓰지 않습니다. - - accessToken을 localStorage/sessionStorage에 저장하면 안 됩니다. - - GraphQL ID는 문자열 그대로 다루며 "0"·빈 문자열을 truthy 검사로 버리면 안 됩니다. - - 색상 hex를 컴포넌트에 직접 쓰지 않고 globals.css 토큰을 씁니다. - - 기능 변경에는 테스트(MSW 기반)가 함께 있는지 확인하세요. - - path: 'src/**/*.graphql' - instructions: | - 문서는 schema/schema.graphql(BE 스냅샷)과 일치해야 합니다. 필드 과다 조회·중복 문서를 지적하세요. - - path: '.github/workflows/**/*.yml' - instructions: | - GitHub Actions는 최소 권한, 시크릿 노출 방지, fork 안전성, SHA 핀을 중심으로 리뷰하세요. - - path: 'infra/**' - instructions: | - compose·nginx·배포 스크립트는 인바운드 포트 미개방, 헬스체크, 롤백 가능성, 시크릿 처리를 중심으로 리뷰하세요. - - path: 'src/**/*.spec.{ts,tsx}' - instructions: | - 네트워크는 MSW로 mock했는지, 정상 흐름뿐 아니라 에러·경계 케이스가 있는지 확인하세요. - - pre_merge_checks: - title: - mode: warning - requirements: 'PR 제목은 Conventional Commits 형식으로 변경 내용을 간결하게 설명해야 합니다.' - description: - mode: off - -knowledge_base: - code_guidelines: - enabled: true - learnings: - scope: local - issues: - scope: local - pull_requests: - scope: local - web_search: - enabled: true - -chat: - auto_reply: false diff --git a/docs/guide/architecture-conventions.md b/docs/guide/architecture-conventions.md index 249e2b0..44a122f 100644 --- a/docs/guide/architecture-conventions.md +++ b/docs/guide/architecture-conventions.md @@ -84,4 +84,4 @@ pnpm codegen # 스냅샷 + 문서 → src/graphql/generated (커밋 - `test`: Vitest 3샤드, 결과는 blob 아티팩트로 넘긴다 - `coverage-report`: blob을 합쳐 임계 검사 → Codecov → PR 댓글. 비교 기준은 base 브랜치(develop·main) push 실행이 올린 커버리지 요약 - 커밋은 Conventional Commits + 한국어 본문(commitlint). 브랜치는 `/<대상>`. -- PR 본문에 `## 플랜 대조` 표. 봇 리뷰(Codex·CodeRabbit)는 BE와 같은 절차로 처리한다. +- PR 본문에 `## 플랜 대조` 표. 봇 리뷰(Codex)는 BE와 같은 절차로 처리한다(CodeRabbit은 BE 전용, D17). diff --git a/docs/guide/decisions.md b/docs/guide/decisions.md index f5b6272..7b42234 100644 --- a/docs/guide/decisions.md +++ b/docs/guide/decisions.md @@ -14,9 +14,10 @@ | D8 | 2026-09-27 | 배포: 이 레포가 Dockerfile(nginx) · `infra/compose.yml` · `deploy.yml` 소유. GHCR `ghcr.io/caquick/caquick-admin-fe:`(arm64), 맥미니 셀프호스트 러너, BE compose 네트워크 `caquick_default`에 external 참여, 기존 cloudflared가 라우팅 | BE와 독립 배포·롤백. BE 레포 변경은 Tunnel 호스트 1줄 | | D9 | 2026-09-27 | 도메인 `admin.caquick.site` → 컨테이너 `caquick-admin:80`. API `https://api.caquick.site` | 같은 부모 도메인이라 refresh 쿠키가 same-site로 전송된다 — 쿠키 도메인 변경 불필요, BE는 CORS 오리진만 추가 | | D10 | 2026-09-27 | 인증: REST `/auth/admin/*`. accessToken은 메모리, refresh는 httpOnly 쿠키. 401이면 refresh 1회 후 재시도, 부팅 시 refresh로 복원, `mustChangePassword`면 변경 화면 강제 | 토큰을 localStorage에 두지 않는다(XSS 반경). 로컬은 Vite 프록시로 same-origin | -| D11 | 2026-09-27 | 머지 게이트는 BE와 동일: terraform ruleset(main·develop), 필수 체크 `check`·`pr-title`·`coverage-report`·`Analyze (javascript-typescript)`, Codex 상태기계, CodeRabbit(main) | 무인 진행의 전제 | +| D11 | 2026-09-27 | 머지 게이트는 BE와 동일: terraform ruleset(main·develop), 필수 체크 `check`·`pr-title`·`coverage-report`·`Analyze (javascript-typescript)`, Codex 상태기계(CodeRabbit은 대체됨 → D17) | 무인 진행의 전제 | | D12 | 2026-09-27 | 릴리즈 순서: R1(기반·CI/CD·인증·셸·대시보드) → main → 영역별 PR → R2 | 배포 파이프라인·Tunnel·쿠키 문제를 가장 이른 시점에 실사이트에서 확인 | | D13 | 2026-09-27 | BE SDL 스냅샷(`schema/schema.graphql`) 커밋 + `pnpm schema:pull`. CI는 codegen 신선도만 게이트, BE main과의 drift는 advisory | CI가 BE 체크아웃에 의존하지 않게 | | D14 | 2026-09-27 | 문서: `CLAUDE.md`·`.claude/`·`AGENTS.md`·`docs/*`(guide 제외)·`.figma/` gitignore. `docs/guide/`·README 커밋 | BE와 같은 방식 | | D15 | 2026-09-27 | 의존 방향은 ESLint boundaries로 강제(shared→features 금지, feature 간은 index.ts만). 검사기는 반증 케이스로 확인하고 넣는다 | BE `arch:check`와 같은 역할 | | D16 | 2026-10-05 | 커버리지 임계를 실측 정수 내림으로 상향: statements 95 · branches 88 · functions 94 · lines 96. CI는 테스트를 3샤드로 나누고 coverage-report가 합친 결과로 검사 | 실측(95.54·88.07·94.29·96.07)보다 한참 낮은 임계는 회귀를 못 잡는다. 샤드 합산이 단일 실행과 같음을 확인 | +| D17 | 2026-10-06 | 리뷰 봇은 Codex만 쓴다. CodeRabbit은 BE 전용이라 `.coderabbit.yaml`을 두지 않는다 | 사용자 결정(판매자 앱도 같음) | From b3d74e03a6a9c16309f9e35a3c1bcdef95865200 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 6 Oct 2026 16:26:59 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20CodeRabbit=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=EB=A5=BC=20=EC=84=A4=EC=A0=95=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=81=84=EA=B8=B0(=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 파일만 지우면 앱 연결이 남은 경우 기본값(auto_review.enabled: true)으로 리뷰가 계속 돎(Codex 지적). - 삭제 대신 auto_review.enabled: false만 둔 최소 설정, decisions 문구 맞춤. --- .coderabbit.yaml | 5 +++++ docs/guide/decisions.md | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..e0a887f --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,5 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +# CodeRabbit은 BE(caquick-be) 전용 — 앱이 이 레포에 연결돼 있어도 자동 리뷰가 돌지 않게 끈다 +reviews: + auto_review: + enabled: false diff --git a/docs/guide/decisions.md b/docs/guide/decisions.md index 7b42234..cc5aa8c 100644 --- a/docs/guide/decisions.md +++ b/docs/guide/decisions.md @@ -20,4 +20,4 @@ | D14 | 2026-09-27 | 문서: `CLAUDE.md`·`.claude/`·`AGENTS.md`·`docs/*`(guide 제외)·`.figma/` gitignore. `docs/guide/`·README 커밋 | BE와 같은 방식 | | D15 | 2026-09-27 | 의존 방향은 ESLint boundaries로 강제(shared→features 금지, feature 간은 index.ts만). 검사기는 반증 케이스로 확인하고 넣는다 | BE `arch:check`와 같은 역할 | | D16 | 2026-10-05 | 커버리지 임계를 실측 정수 내림으로 상향: statements 95 · branches 88 · functions 94 · lines 96. CI는 테스트를 3샤드로 나누고 coverage-report가 합친 결과로 검사 | 실측(95.54·88.07·94.29·96.07)보다 한참 낮은 임계는 회귀를 못 잡는다. 샤드 합산이 단일 실행과 같음을 확인 | -| D17 | 2026-10-06 | 리뷰 봇은 Codex만 쓴다. CodeRabbit은 BE 전용이라 `.coderabbit.yaml`을 두지 않는다 | 사용자 결정(판매자 앱도 같음) | +| D17 | 2026-10-06 | 리뷰 봇은 Codex만 쓴다. CodeRabbit은 BE 전용 — `.coderabbit.yaml`은 자동 리뷰를 끈 최소 설정만 둔다 | 사용자 결정(판매자 앱도 같음) | From 7bc769cda0ab93d948237b0b5e5041b3f4268aca Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 6 Oct 2026 16:36:30 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20CodeRabbit=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=8B=B5=EA=B8=80=EB=8F=84=20=EB=81=88=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80(=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최소 설정에서 이전의 chat.auto_reply: false가 빠져 기본값(true)으로 돌아감(Codex 지적) → 유지. --- .coderabbit.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index e0a887f..600dd51 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,5 +1,7 @@ # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json -# CodeRabbit은 BE(caquick-be) 전용 — 앱이 이 레포에 연결돼 있어도 자동 리뷰가 돌지 않게 끈다 +# CodeRabbit은 BE(caquick-be) 전용 — 앱이 이 레포에 연결돼 있어도 자동 리뷰·자동 답글이 돌지 않게 끈다 reviews: auto_review: enabled: false +chat: + auto_reply: false From ecac7968322c6576ebe1c046d71089d14979a2aa Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 8 Oct 2026 10:21:04 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EA=B3=84=EC=A0=95=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EB=94=94=EC=97=90=20=EC=98=81=EB=AC=B8=20=EB=8C=80?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BE #526과 같은 규칙으로 관리자·판매자 생성 폼의 아이디 검증을 완화함. - 정규식: [a-z0-9._-]{4,80} → [A-Za-z0-9._-]{4,80}(create-admin-schema·create-seller-schema) - 오류 문구: "영문 소문자" → "영문" - 도움말: 로그인할 때 대소문자를 구분하지 않는다는 안내 추가(BE 컬럼 정렬이 ci라 대소문자만 다른 아이디는 같은 아이디로 봄) 테스트 - createAdminSchema: 대문자 아이디 허용, 허용 외 문자(@) 거절 케이스 추가 - createSellerSchema: 대문자 거절 케이스를 허용 외 문자로 교체, 대문자 아이디를 입력 그대로 보내는 케이스 추가 - admins 페이지: 바뀐 오류 문구로 맞춤 --- src/features/account/components/create-admin-dialog.tsx | 2 +- src/features/account/create-admin-schema.spec.ts | 2 ++ src/features/account/create-admin-schema.ts | 6 +++--- src/features/account/pages/admins.spec.tsx | 2 +- src/features/sellers/components/create-seller-form.tsx | 2 +- src/features/sellers/create-seller-schema.spec.ts | 7 ++++++- src/features/sellers/create-seller-schema.ts | 4 ++-- 7 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/features/account/components/create-admin-dialog.tsx b/src/features/account/components/create-admin-dialog.tsx index 749aca8..5bf16c8 100644 --- a/src/features/account/components/create-admin-dialog.tsx +++ b/src/features/account/components/create-admin-dialog.tsx @@ -38,7 +38,7 @@ const FIELDS: { name: 'username', label: '아이디', autoComplete: 'off', - help: '4~80자의 영문 소문자, 숫자, 마침표(.), 밑줄(_), 하이픈(-)을 쓸 수 있습니다.', + help: '4~80자의 영문, 숫자, 마침표(.), 밑줄(_), 하이픈(-)을 쓸 수 있습니다. 로그인할 때 대소문자는 구분하지 않습니다.', }, { name: 'password', diff --git a/src/features/account/create-admin-schema.spec.ts b/src/features/account/create-admin-schema.spec.ts index 65a1ccb..e181d90 100644 --- a/src/features/account/create-admin-schema.spec.ts +++ b/src/features/account/create-admin-schema.spec.ts @@ -6,6 +6,8 @@ describe('createAdminSchema', () => { it.each([ [{}, true], [{ username: 'Ops' }, false], + [{ username: 'Ops.Admin_1' }, true], + [{ username: 'ops@admin' }, false], [{ email: 'nope' }, false], [{ email: 'a@b.co', name: '운영' }, true], ])('%j → %s', (patch, ok) => { diff --git a/src/features/account/create-admin-schema.ts b/src/features/account/create-admin-schema.ts index 979ae02..9373fbf 100644 --- a/src/features/account/create-admin-schema.ts +++ b/src/features/account/create-admin-schema.ts @@ -2,14 +2,14 @@ import { z } from 'zod'; import { initialPasswordSchema } from '@/shared/lib/initial-password'; -/** BE AdminCreateAdminInput 규칙: 아이디 4~80 소문자·숫자·._-, 초기 비밀번호 8~64자. */ +/** BE AdminCreateAdminInput 규칙: 아이디 4~80 영문·숫자·._-, 초기 비밀번호 8~64자. */ export const createAdminSchema = z.object({ username: z .string() .trim() .regex( - /^[a-z0-9._-]{4,80}$/, - '아이디는 4~80자의 영문 소문자, 숫자, 마침표(.), 밑줄(_), 하이픈(-)으로 입력해 주세요.', + /^[A-Za-z0-9._-]{4,80}$/, + '아이디는 4~80자의 영문, 숫자, 마침표(.), 밑줄(_), 하이픈(-)으로 입력해 주세요.', ), password: initialPasswordSchema, email: z.string().trim().email('이메일 형식이 아닙니다.').or(z.literal('')), diff --git a/src/features/account/pages/admins.spec.tsx b/src/features/account/pages/admins.spec.tsx index a38089e..eca17aa 100644 --- a/src/features/account/pages/admins.spec.tsx +++ b/src/features/account/pages/admins.spec.tsx @@ -94,7 +94,7 @@ describe('관리자 계정', () => { await userEvent.click(within(dialog).getByRole('button', { name: '추가' })); expect( await within(dialog).findByText( - '아이디는 4~80자의 영문 소문자, 숫자, 마침표(.), 밑줄(_), 하이픈(-)으로 입력해 주세요.', + '아이디는 4~80자의 영문, 숫자, 마침표(.), 밑줄(_), 하이픈(-)으로 입력해 주세요.', ), ).toBeInTheDocument(); await userEvent.type(within(dialog).getByLabelText('아이디'), 'new.admin'); diff --git a/src/features/sellers/components/create-seller-form.tsx b/src/features/sellers/components/create-seller-form.tsx index cdb4cb4..0a1f677 100644 --- a/src/features/sellers/components/create-seller-form.tsx +++ b/src/features/sellers/components/create-seller-form.tsx @@ -37,7 +37,7 @@ const TEXT: { label: '아이디', required: true, autoComplete: 'off', - help: '4~80자의 영문 소문자, 숫자, 마침표(.), 밑줄(_), 하이픈(-)을 쓸 수 있습니다.', + help: '4~80자의 영문, 숫자, 마침표(.), 밑줄(_), 하이픈(-)을 쓸 수 있습니다. 로그인할 때 대소문자는 구분하지 않습니다.', }, { name: 'password', diff --git a/src/features/sellers/create-seller-schema.spec.ts b/src/features/sellers/create-seller-schema.spec.ts index a7b6f1c..dc8fda1 100644 --- a/src/features/sellers/create-seller-schema.spec.ts +++ b/src/features/sellers/create-seller-schema.spec.ts @@ -50,7 +50,7 @@ describe('createSellerSchema', () => { }); it.each([ - ['username', 'Lumi'], // 대문자 + ['username', 'lumi@shop'], // 허용 외 문자 ['username', 'ab'], // 짧음 ['password', '1234567'], // 7자 ['password', 'a'.repeat(65)], @@ -63,6 +63,11 @@ describe('createSellerSchema', () => { expect(createSellerSchema.safeParse({ ...base, [key]: value }).success).toBe(false); }); + it('아이디에 영문 대문자를 받고 입력 그대로 보낸다', () => { + const parsed = createSellerSchema.parse({ ...base, username: 'Lumi.Cake' }); + expect(toCreateSellerInput(parsed).username).toBe('Lumi.Cake'); + }); + // BE 상한: auth-admin.constants(name·email), store-field-limits(나머지) it.each([ ['name', 100], diff --git a/src/features/sellers/create-seller-schema.ts b/src/features/sellers/create-seller-schema.ts index 4318e05..cd02e3f 100644 --- a/src/features/sellers/create-seller-schema.ts +++ b/src/features/sellers/create-seller-schema.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { refineCoordPair, storeLocationShape } from '@/features/stores'; import { initialPasswordSchema } from '@/shared/lib/initial-password'; -const USERNAME = /^[a-z0-9._-]{4,80}$/; +const USERNAME = /^[A-Za-z0-9._-]{4,80}$/; /** BE 상한(auth-admin.constants·store-field-limits). BE는 trim 뒤 코드 포인트로 센다. */ const SELLER_FIELD_MAX = { @@ -32,7 +32,7 @@ const sellerFields = z.object({ .trim() .regex( USERNAME, - '아이디는 4~80자의 영문 소문자, 숫자, 마침표(.), 밑줄(_), 하이픈(-)으로 입력해 주세요.', + '아이디는 4~80자의 영문, 숫자, 마침표(.), 밑줄(_), 하이픈(-)으로 입력해 주세요.', ), password: initialPasswordSchema, email: maxChars(SELLER_FIELD_MAX.email) From 0dd12e58f45e2ae7109e29f01ac27a3f7167eb03 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 8 Oct 2026 10:52:44 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20BE=20SDL=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20=EB=8F=99=EA=B8=B0=ED=99=94(b3fe44f)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BE 릴리즈 #527(main b3fe44f) 기준 schema:pull → codegen. - 생성 타입 변경: AdminCreateAdminInput·AdminCreateSellerInput.username 설명(영문 대소문자 허용)만 - 스냅샷에는 그동안 반영 안 된 판매자 앱용 SDL 추가분도 함께 들어옴(관리자 FE 문서가 쓰지 않아 생성물 영향 없음) --- schema/schema.graphql | 360 +++++++++++++++++++++++++++++-- src/graphql/generated/graphql.ts | 4 +- 2 files changed, 347 insertions(+), 17 deletions(-) diff --git a/schema/schema.graphql b/schema/schema.graphql index 36852f3..85f87d0 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -1,4 +1,4 @@ -# 생성 파일 — 수정하지 않는다. caquick-be local@746d6fa87c2438aeef9afd739885f33274e1f021 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. +# 생성 파일 — 수정하지 않는다. caquick-be main@b3fe44f45b6940700e03723f7c6abe17f1656a29 의 src/features/**/*.graphql 68개를 경로순으로 합쳤다. # 갱신: pnpm schema:pull [ref] # ---- src/features/audit-log/audit-log.types.graphql ---- @@ -178,7 +178,7 @@ type AdminAccountConnection { """ input AdminCreateAdminInput { """ - 로그인 username. 4~80자, 소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면 BAD_USER_INPUT. + 로그인 username. 4~80자, 영문 대소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면(대소문자만 달라도) BAD_USER_INPUT. """ username: String! """ @@ -363,6 +363,45 @@ type AdminAccountStatusResult { status: AccountStatus! } +# ---- src/features/auth/auth-seller-account.graphql ---- +extend type Query { + """ + 내 판매자 계정을 조회한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN. + 초기 비밀번호 상태(mustChangePassword=true)면 다른 판매자 API와 같이 FORBIDDEN이므로, 초기 비밀번호 여부는 refresh 응답의 mustChangePassword로 판단한다. + """ + sellerMe: SellerAccount! +} + +""" +판매자 본인 계정. 로그인 자격증명·계정 상태·보유 매장 ID를 함께 보여준다. 매장명은 sellerMyStore로 받는다. +""" +type SellerAccount { + """ + 계정 ID. + """ + accountId: ID! + """ + 로그인 username. 자격증명이 없는 개발용 계정이면 null. + """ + username: String + """ + 표시 이름. 계정 이름이 있으면 그것, 없으면 사업자명, 둘 다 없으면 null. + """ + displayName: String + """ + 보유 매장 ID. 매장이 없거나 삭제됐으면 null. + """ + storeId: ID + """ + 초기 비밀번호 상태. true면 본인이 비밀번호를 바꾸기 전까지 판매자 API가 FORBIDDEN이다. + """ + mustChangePassword: Boolean! + """ + 계정 상태. + """ + accountStatus: AccountStatus! +} + # ---- src/features/auth/auth-user-profile.graphql ---- extend type Query { """ @@ -788,6 +827,13 @@ extend type Mutation { sellerSendConversationMessage( input: SellerSendConversationMessageInput! ): SellerConversationMessage! + """ + 대화방을 판매자가 읽었다고 표시한다. 호출 시점에 존재하는 가장 최근 메시지의 작성 시각까지 sellerLastReadAt을 + 전진시키며 과거로 되돌리지 않는다(반복·동시 호출 안전). 메시지가 없으면 변경 없이 현재 상태를 돌려준다. + 조회만으로는 읽음 처리되지 않으므로 앱이 상세 진입 시 호출한다. 판매자 답장은 서버가 마커를 함께 전진시킨다. + 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않거나 내 매장의 대화가 아니면 NOT_FOUND. + """ + sellerMarkConversationRead(conversationId: ID!): SellerConversation! } """ @@ -801,13 +847,30 @@ type SellerConversation { accountId: ID! storeId: ID! """ + 대화 상대인 구매자의 표시명. 대화 생성 시점 스냅샷이라 이후 닉네임 변경·탈퇴가 반영되지 않는다. + 스냅샷 도입 이전 대화 중 복원하지 못한 건은 null. + """ + buyerNickname: String + """ + 마지막 메시지 미리보기(HTML은 태그 제거). 메시지가 없으면 null. + """ + lastMessagePreview: String + """ 마지막 메시지 시각. 메시지가 없으면 null. 목록 정렬 기준은 이 값이 아니라 updated_at이다. """ lastMessageAt: DateTime """ - 구매자가 이 대화방을 마지막으로 읽은 시각(구매자 조회 시 전진). 판매자 읽음 마커는 없다. 읽은 적이 없으면 null. + 구매자가 이 대화방을 마지막으로 읽은 시각(구매자 조회 시 전진). 읽은 적이 없으면 null. """ lastReadAt: DateTime + """ + 판매자가 마지막으로 읽은 시각. sellerMarkConversationRead 호출과 판매자 답장이 전진시킨다. 읽은 적이 없으면 null. + """ + sellerLastReadAt: DateTime + """ + 판매자 기준 읽지 않은 구매자 메시지 수(senderType USER이고 sellerLastReadAt 이후 작성). 0이면 답변 필요 대상이 아니다. + """ + unreadCount: Int! updatedAt: DateTime! } @@ -949,15 +1012,25 @@ type ConversationListUpdate { unreadCount: Int! } -"""판매자 대화 목록 갱신 이벤트. 도착 순서 비보장 — lastMessageAt 기준 폐기 규칙 동일.""" +""" +판매자 대화 목록 갱신 이벤트(구매자 메시지 도착·판매자 답장). 도착 순서 비보장 — (lastMessageAt, sellerLastReadAt) +사전식 비교로 폐기한다. 읽음 mutation은 이벤트를 내지 않으므로 로컬 읽음 처리 뒤 도착한 이벤트의 unreadCount는 +로컬 sellerLastReadAt이 더 크면 0으로 본다. +""" type SellerConversationListUpdate { conversationId: ID! """대화 상대인 구매자 계정 ID.""" accountId: ID! + """대화 상대 구매자 표시명(대화 생성 시점 스냅샷). 복원 불가 건은 null.""" + buyerNickname: String """마지막 메시지 미리보기(HTML은 태그 제거)""" lastMessagePreview: String - """마지막 메시지 시각. 폐기 규칙의 비교 키다.""" + """마지막 메시지 시각. 폐기 규칙의 1차 비교 키다.""" lastMessageAt: DateTime! + """이벤트 스냅샷 시점의 판매자 마지막 읽음 시각(폐기 규칙 비교용).""" + sellerLastReadAt: DateTime + """이벤트 시점의 판매자 기준 미읽음 구매자 메시지 수.""" + unreadCount: Int! } # ---- src/features/conversation/conversation.types.graphql ---- @@ -1304,6 +1377,87 @@ type AdminSearchKeywordRank { searchCount: Int! } +# ---- src/features/dashboard/dashboard-seller.graphql ---- +extend type Query { + """ + 판매자 홈 지표를 요청 시점에 집계한다(스냅샷 테이블 없음). 날짜는 KST 달력일이며 생략하면 서버의 오늘(KST). + 형식이 YYYY-MM-DD가 아니거나 존재하지 않는 날짜면 BAD_USER_INPUT. 판매자 로그인 필수. + SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. + """ + sellerDashboard(input: SellerDashboardInput): SellerDashboard! +} + +""" +판매자 홈 집계 조건. +""" +input SellerDashboardInput { + """ + 집계 기준 날짜(KST, YYYY-MM-DD). 생략하면 오늘. + """ + date: String +} + +""" +판매자 홈 지표. 금액은 원 단위 정수다. +""" +type SellerDashboard { + """ + 집계 기준 날짜(KST, YYYY-MM-DD). + """ + date: String! + """ + 집계 시점(서버 시각). 화면의 'N시 기준' 표기용이며 date가 오늘이 아니어도 현재 시각이다. + """ + asOf: DateTime! + """ + 접수(SUBMITTED) 상태로 남아 있는 주문 수. 날짜와 무관하게 전체를 센다. + """ + newOrderCount: Int! + """ + 기준일에 픽업 예정인 주문의 건수·금액. 취소 주문은 제외. 시안의 '오늘 매출 / 정산예정'에 해당한다. + """ + pickupDay: SellerDashboardOrderMetrics! + """ + 기준일에 생성된 주문의 건수·금액. 취소 주문은 제외. 접수 추세 표기('+N')용. + """ + createdDay: SellerDashboardOrderMetrics! + """ + 기준일의 제작 수량 설정값. 설정이 없으면 null(무제한). + """ + capacity: Int + """ + 기준일의 남은 제작 수량 = 설정값 − 기준일 픽업 주문의 수량 합(취소 제외). 음수가 되면 0으로 자른다. + 설정이 없으면 null. + """ + remainingCapacity: Int + """ + 기준일 픽업 주문의 제작 수량 합(취소 제외). capacity 유무와 무관하게 센다. + """ + bookedQuantity: Int! + """ + 노출 중(is_active)인 내 상품 수. 매장 노출 여부와 무관하다. + """ + activeProductCount: Int! + """ + 답변이 필요한 대화 수 = 판매자 기준 읽지 않은 구매자 메시지가 있는 대화 수. + """ + unansweredConversationCount: Int! +} + +""" +기간 내 주문 건수와 결제 금액 합. +""" +type SellerDashboardOrderMetrics { + """ + 주문 건수. + """ + orderCount: Int! + """ + 최종 결제 금액 합(원). Int(32비트)를 넘을 수 있어 Float로 내리지만 값은 항상 정수다. + """ + salesAmount: Float! +} + # ---- src/features/mypage/mypage-overview.graphql ---- extend type Query { """ @@ -2193,6 +2347,65 @@ type NotificationItem { createdAt: DateTime! } +# ---- src/features/notification/notification-seller-push.graphql ---- +extend type Mutation { + """ + 판매자 앱 디바이스의 Expo 푸시 토큰을 등록한다. 판매자 로그인 필수(SELLER 아니면 FORBIDDEN, 매장 없으면 NOT_FOUND). + 같은 토큰을 다시 보내면 마지막 확인 시각만 갱신되고, 다른 판매자가 쓰던 토큰이면 현재 로그인 판매자가 가져간다(앱 재설치·계정 전환). + 해제됐던 토큰은 다시 활성화된다. 토큰이 Expo 형식이 아니면 BAD_REQUEST(INVALID_PUSH_TOKEN). + 앱 시작·로그인마다 호출해도 된다(멱등). + """ + sellerRegisterPushToken(input: SellerRegisterPushTokenInput!): Boolean! + """ + 푸시 토큰을 해제한다(로그아웃). 판매자 로그인 필수(SELLER 아니면 FORBIDDEN). + 매장 컨텍스트를 보지 않으므로 매장이 없는 판매자도 해제할 수 있다. + 본인이 등록한 토큰만 해제되며, 없거나 남의 토큰이면 아무 일도 하지 않고 true다. + """ + sellerUnregisterPushToken(input: SellerUnregisterPushTokenInput!): Boolean! +} + +""" +푸시 토큰 등록 입력 +""" +input SellerRegisterPushTokenInput { + """ + Expo 푸시 토큰. ExponentPushToken[...] 또는 ExpoPushToken[...] 형식, 최대 200자 + """ + token: String! + """ + 디바이스 플랫폼 + """ + platform: PushPlatform! + """ + 앱이 부여한 디바이스 식별자(선택, 최대 128자). 같은 기기의 토큰 교체 추적용이며 서버 로직은 토큰만 본다 + """ + deviceId: String +} + +""" +푸시 토큰 해제 입력 +""" +input SellerUnregisterPushTokenInput { + """ + 해제할 Expo 푸시 토큰 + """ + token: String! +} + +""" +푸시 디바이스 플랫폼 +""" +enum PushPlatform { + """ + iOS(APNs 경유) + """ + IOS + """ + Android(FCM 경유) + """ + ANDROID +} + # ---- src/features/notification/notification.types.graphql ---- """ 알림 분류. 알림센터 목록의 그룹 기준이다. @@ -2308,6 +2521,14 @@ type AdminOrderSummary { 주문 생성 시각. """ createdAt: DateTime! + """ + 첫 품목의 상품명(주문 시점 스냅샷). 이후 상품명이 바뀌어도 유지된다. 품목이 없으면 null. + """ + firstItemName: String + """ + 첫 품목의 상품 대표 이미지 URL(주문 시점 스냅샷). 주문 당시 이미지가 없었거나 품목이 없으면 null. + """ + firstItemImageUrl: String } """ @@ -2816,6 +3037,14 @@ type SellerOrderSummary { 주문 생성 시각. 목록 정렬 기준이다. """ createdAt: DateTime! + """ + 내 매장 첫 품목의 상품명(주문 시점 스냅샷). 이후 상품명이 바뀌어도 유지된다. 활성 품목이 없으면 null. + """ + firstItemName: String + """ + 내 매장 첫 품목의 상품 대표 이미지 URL(주문 시점 스냅샷). 주문 당시 이미지가 없었으면 null. + """ + firstItemImageUrl: String } """ @@ -2967,6 +3196,40 @@ input SellerUpdateOrderStatusInput { note: String } +# ---- src/features/order/order-subscription.graphql ---- +extend type Subscription { + """ + 내 매장 주문 생성·상태 변경 이벤트. 판매자 로그인 필수(SELLER 아니면 FORBIDDEN). + 삭제되지 않은 매장을 보유한 판매자만 구독할 수 있다 — 매장이 없거나 삭제됐으면 NOT_FOUND. + 비활성(is_active=false) 매장도 구독은 유지된다. 인증은 graphql-ws connectionParams.authorization. + 구매자 주문 생성(SUBMITTED)·판매자 상태 변경·관리자 강제 취소가 모두 여기로 온다. + """ + sellerOrderUpdated: SellerOrderUpdate! +} + +""" +판매자 주문 갱신 이벤트. 발행 시점 주문 행의 스냅샷이다. +이벤트 간 도착 순서는 보장되지 않는다 — 구독자는 같은 orderId의 더 오래된 updatedAt 이벤트는 폐기한다. +전달 누락 가능(Redis 장애 시 발행 생략)하므로 폴백은 sellerOrderList 재조회다. +""" +type SellerOrderUpdate { + orderId: ID! + """주문번호.""" + orderNumber: String! + """이벤트 시점의 주문 상태.""" + status: OrderStatusType! + """픽업 예정 일시.""" + pickupAt: DateTime! + """주문자 이름(주문 시점 값).""" + buyerName: String! + """할인 반영 후 최종 결제 금액(원).""" + totalPrice: Int! + """첫 품목의 상품명(주문 시점 스냅샷). 주문은 단일 상품 구조다.""" + productName: String! + """주문 행의 updated_at. 폐기 규칙의 비교 키다.""" + updatedAt: DateTime! +} + # ---- src/features/order/order.types.graphql ---- """ 주문 상태. 구매자·판매자 API가 공용으로 쓴다. @@ -3351,6 +3614,7 @@ extend type Mutation { adminDeleteCategory(categoryId: ID!): Boolean! """ 태그를 만든다. 같은 이름이 있으면 BAD_USER_INPUT. 삭제된 같은 이름이 있으면 복구한다(상품 연결은 복구하지 않는다). + 판매자가 sellerSetProductTagsByName으로 만든 태그도 같은 마스터에 있으므로 충돌 대상이다. 관리자 로그인 필수. ADMIN 계정이 아니면 FORBIDDEN. """ adminCreateTag(input: AdminCreateTagInput!): AdminTag! @@ -3398,7 +3662,7 @@ type AdminCategory { } """ -태그 마스터. 판매자는 여기 있는 것 중에서만 상품에 연결할 수 있다. +태그 마스터. 관리자가 만든 태그와 판매자가 sellerSetProductTagsByName으로 만든 태그(소문자 정규화)를 모두 포함한다. """ type AdminTag { id: ID! @@ -4304,6 +4568,12 @@ extend type Query { 내 매장 상품 상세를 조회한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. """ sellerProduct(productId: ID!): SellerProduct! + """ + 내 상품에 붙일 태그를 이름으로 검색한다(타이프어헤드). keyword를 정규화(앞뒤 공백·선행 '#' 제거, 연속 공백 1칸, 소문자)한 뒤 + 부분일치로 찾아 이름순(정확 일치가 맨 앞)으로 limit건까지 준다. 정확 일치 태그는 이름순 limit 밖이어도 항상 포함한다. + 정규화 후 비면 빈 배열, 80자를 넘으면 BAD_USER_INPUT. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. + """ + sellerSearchTags(input: SellerTagSearchInput!): [SellerTagSuggestion!]! } extend type Mutation { @@ -4349,6 +4619,17 @@ extend type Mutation { 상품 태그 연결을 설정한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. """ sellerSetProductTags(input: SellerSetProductTagsInput!): SellerProduct! + """ + 상품 태그 연결을 이름 목록으로 통째로 교체한다. 각 이름은 앞뒤 공백·선행 '#' 제거, 연속 공백 1칸, 소문자로 정규화하고 + 정규화 후 같은 이름은 하나로 합친다(대소문자·악센트가 같은 이름도 DB 기준 하나로 저장되지만, 개수 검사는 정규화 문자열 + 기준이라 악센트만 다른 café·cafe는 각각 센다). 없는 이름은 새 태그로 만들고 + (삭제된 같은 이름은 복구), 있으면 저장된 형태 그대로 재사용한다. 빈 목록이면 전체 해제. + 정규화 후 빈 이름이 있거나 80자(코드 포인트, 정규화 전후 모두)를 넘는 이름이 있거나 합친 뒤 20개를 넘으면 BAD_USER_INPUT. + 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않거나 내 상품이 아니면 NOT_FOUND. + """ + sellerSetProductTagsByName( + input: SellerSetProductTagsByNameInput! + ): SellerProduct! """ 상품 옵션 그룹을 생성한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. @@ -4406,6 +4687,8 @@ extend type Mutation { ): SellerCustomTemplate! """ 커스텀 텍스트 토큰을 등록 또는 수정한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. + 등록 시 같은 템플릿에 같은 tokenKey의 삭제된 슬롯이 있으면 그 슬롯(같은 id)을 복구해 입력값으로 갱신한다. + 활성 슬롯과 tokenKey가 겹치면(수정으로 삭제된 슬롯의 키로 바꾸는 경우 포함) CUSTOM_TEXT_TOKEN_KEY_TAKEN. """ sellerUpsertProductCustomTextToken( input: SellerUpsertProductCustomTextTokenInput! @@ -4444,6 +4727,25 @@ type SellerTag { name: String! } +""" +태그 제안 1건. 정확 일치 여부로 FE가 '새 태그 만들기' 행을 띄울지 판단한다. +""" +type SellerTagSuggestion { + id: ID! + """ + 태그명(저장된 형태). + """ + name: String! + """ + 정규화한 keyword와 이름이 같으면 true(대소문자·악센트 무시). 목록에 true가 없으면 FE는 '새로 만들기'를 보여준다. + """ + isExactMatch: Boolean! + """ + 이 태그가 붙은 활성 상품 수(전 매장). 표시용 근사치이며 정렬에는 쓰지 않는다. + """ + productCount: Int! +} + """ 상품 이미지 1장. """ @@ -4570,19 +4872,20 @@ type SellerCustomTextToken { """ isRequired: Boolean! """ - 베이스 이미지 위에서 문구가 놓일 가로 위치. 미지정 시 null이고 배치는 클라이언트가 정한다. + 문구 영역 왼쪽 위 모서리의 가로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000)이고 서버는 범위를 검사하지 않는다. + 미지정 시 null이고 배치는 클라이언트가 정한다. """ posX: Int """ - 베이스 이미지 위에서 문구가 놓일 세로 위치. 미지정 시 null. + 문구 영역 왼쪽 위 모서리의 세로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000). 미지정 시 null. """ posY: Int """ - 문구 영역 너비. 미지정 시 null. + 문구 영역 너비. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000). 미지정 시 null. """ width: Int """ - 문구 영역 높이. 미지정 시 null. + 문구 영역 높이. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000). 미지정 시 null. """ height: Int } @@ -4869,6 +5172,32 @@ input SellerSetProductTagsInput { tagIds: [ID!]! } +""" +이름 기반 태그 연결 설정 입력. 전달한 목록으로 통째로 교체한다. +""" +input SellerSetProductTagsByNameInput { + productId: ID! + """ + 연결할 태그 이름 전체. 정규화·중복 제거 뒤 최대 20개(개수는 정규화 문자열 기준 — 악센트만 다른 이름은 저장 시 하나로 + 합쳐져도 각각 센다). 각 이름은 정규화 전후 모두 80자(코드 포인트) 이내. 빈 배열이면 전체 해제. + """ + names: [String!]! +} + +""" +태그 검색 입력. +""" +input SellerTagSearchInput { + """ + 검색어. 정규화 후 80자를 넘으면 BAD_USER_INPUT. + """ + keyword: String! + """ + 최대 건수. 기본 10, 1~20만 허용하며 벗어나면 BAD_USER_INPUT. + """ + limit: Int = 10 +} + """ 옵션 그룹 생성 입력. """ @@ -5097,19 +5426,20 @@ input SellerUpsertProductCustomTextTokenInput { """ isRequired: Boolean = true """ - 베이스 이미지 위 가로 위치. 생략 시 배치를 클라이언트가 정한다. + 문구 영역 왼쪽 위 모서리의 가로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000)이고 서버는 범위를 검사하지 않는다. + 생략 시 배치를 클라이언트가 정한다. """ posX: Int """ - 베이스 이미지 위 세로 위치. + 문구 영역 왼쪽 위 모서리의 세로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000). """ posY: Int """ - 문구 영역 너비. + 문구 영역 너비. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000). """ width: Int """ - 문구 영역 높이. + 문구 영역 높이. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000). """ height: Int } @@ -6980,7 +7310,7 @@ input AdminSellerListInput { """ input AdminCreateSellerInput { """ - 로그인 username. 4~80자, 소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면 BAD_USER_INPUT. + 로그인 username. 4~80자, 영문 대소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면(대소문자만 달라도) BAD_USER_INPUT. """ username: String! """ diff --git a/src/graphql/generated/graphql.ts b/src/graphql/generated/graphql.ts index 3306926..4bf07d3 100644 --- a/src/graphql/generated/graphql.ts +++ b/src/graphql/generated/graphql.ts @@ -79,7 +79,7 @@ export type AdminCreateAdminInput = { name?: string | null | undefined; /** 초기 비밀번호. 8~64자(공백만으로는 불가). 최초 로그인 시 변경이 강제되므로 조합 규칙은 없다. */ password: string; - /** 로그인 username. 4~80자, 소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면 BAD_USER_INPUT. */ + /** 로그인 username. 4~80자, 영문 대소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면(대소문자만 달라도) BAD_USER_INPUT. */ username: string; }; @@ -169,7 +169,7 @@ export type AdminCreateSellerInput = { password: string; /** 매장 기본 정보. */ store: AdminCreateSellerStoreInput; - /** 로그인 username. 4~80자, 소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면 BAD_USER_INPUT. */ + /** 로그인 username. 4~80자, 영문 대소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면(대소문자만 달라도) BAD_USER_INPUT. */ username: string; /** 홈페이지·SNS URL. 선택. */ websiteUrl?: string | null | undefined;