From 16bd2cb92a95de8e07ed12a817233096848b504d Mon Sep 17 00:00:00 2001 From: Johngreen Date: Sat, 7 Mar 2026 17:39:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Re:Link=20MVP=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20=EB=8F=84=EB=A9=94=EC=9D=B8/=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4/=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=84=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모노레포 구조 (Turborepo + pnpm): @relink/domain, @relink/shared, @relink/infrastructure, @relink/database, @relink/web - 도메인 레이어: 매장(store), 매칭(matching), 업체(vendor), 보조금(subsidy), 계약/에스크로(contract) TDD 완료 (158 단위 테스트) - 서비스 레이어: 전 도메인 서비스 함수 + 통합 테스트 (58 테스트) - 프론트엔드: Next.js 15 App Router, 13개 페이지 (사용자 6 + 관리자 7) - 인프라: PostgreSQL 16 + PostGIS, Prisma ORM, Docker Compose, AuditLog + OutboxEvent 패턴 - .env 파일 포함 (로컬 개발 기본값만 포함, 실제 시크릿 없음) Co-Authored-By: Claude Opus 4.6 --- .env | 13 + .env.example | 13 + .eslintrc.cjs | 35 + .gitignore | 45 + .nvmrc | 1 + .prettierrc | 8 + DEVELOPMENT_PLAN.md | 541 ++ apps/admin/.eslintrc.cjs | 5 + apps/admin/next-env.d.ts | 6 + apps/admin/next.config.ts | 7 + apps/admin/package.json | 32 + apps/admin/postcss.config.mjs | 8 + apps/admin/src/app/globals.css | 1 + apps/admin/src/app/layout.tsx | 20 + apps/admin/src/app/page.tsx | 8 + apps/admin/tsconfig.json | 24 + apps/web/.eslintrc.cjs | 5 + apps/web/next-env.d.ts | 6 + apps/web/next.config.ts | 7 + apps/web/package.json | 39 + apps/web/postcss.config.mjs | 8 + .../contract-service.integration.test.ts | 721 +++ .../match-request-service.integration.test.ts | 419 ++ .../store-service.integration.test.ts | 559 ++ .../subsidy-case-service.integration.test.ts | 403 ++ ...-certification-service.integration.test.ts | 305 + apps/web/src/app/admin/contracts/page.tsx | 103 + apps/web/src/app/admin/layout.tsx | 33 + apps/web/src/app/admin/page.tsx | 119 + apps/web/src/app/admin/stores/page.tsx | 79 + apps/web/src/app/admin/subsidies/page.tsx | 75 + apps/web/src/app/admin/vendors/page.tsx | 84 + apps/web/src/app/api/v1/master-data/route.ts | 37 + .../app/api/v1/stores/[id]/submit/route.ts | 30 + apps/web/src/app/api/v1/stores/route.ts | 59 + apps/web/src/app/contracts/page.tsx | 130 + apps/web/src/app/globals.css | 1 + apps/web/src/app/layout.tsx | 62 + apps/web/src/app/matching/page.tsx | 95 + apps/web/src/app/page.tsx | 86 + apps/web/src/app/stores/[id]/page.tsx | 89 + apps/web/src/app/stores/new/page.tsx | 155 + apps/web/src/app/stores/page.tsx | 134 + apps/web/src/app/subsidies/page.tsx | 100 + apps/web/src/app/vendors/page.tsx | 112 + apps/web/src/services/contract-service.ts | 408 ++ .../web/src/services/match-request-service.ts | 223 + apps/web/src/services/store-service.ts | 407 ++ apps/web/src/services/subsidy-case-service.ts | 213 + .../services/vendor-certification-service.ts | 197 + apps/web/tsconfig.json | 24 + apps/web/vitest.config.ts | 18 + docker-compose.yml | 43 + docs/analytics/event-schema.md | 1589 +++++ docs/database/schema-prisma-draft.md | 876 +++ docs/master-data/beta-master-data.md | 223 + docs/policies/contract-escrow-policy.md | 585 ++ docs/policies/data-exposure-policy.md | 500 ++ docs/policies/subsidy-policy.md | 594 ++ package.json | 32 + packages/analytics/package.json | 29 + packages/analytics/src/event.ts | 22 + packages/analytics/src/index.ts | 6 + packages/analytics/tsconfig.json | 9 + packages/analytics/tsup.config.ts | 10 + packages/analytics/vitest.config.ts | 8 + packages/application/package.json | 30 + packages/application/src/index.ts | 7 + packages/application/src/use-case.ts | 8 + packages/application/tsconfig.json | 9 + packages/application/tsup.config.ts | 10 + packages/application/vitest.config.ts | 8 + packages/database/.env | 1 + packages/database/package.json | 39 + packages/database/prisma/schema.prisma | 1144 ++++ .../seeds/master-data/industries.v1.csv | 9 + .../database/seeds/master-data/regions.v1.csv | 13 + packages/database/seeds/seed.ts | 157 + packages/database/src/client.ts | 26 + packages/database/src/index.ts | 12 + packages/database/src/test-helpers.ts | 194 + packages/database/tsconfig.json | 9 + packages/database/tsup.config.ts | 9 + packages/database/vitest.config.ts | 8 + packages/domain/package.json | 29 + .../__tests__/check-idempotency.test.ts | 54 + .../__tests__/create-contract.test.ts | 95 + .../contract/__tests__/open-dispute.test.ts | 98 + .../contract/__tests__/release-escrow.test.ts | 59 + .../domain/src/contract/check-idempotency.ts | 34 + .../domain/src/contract/create-contract.ts | 51 + packages/domain/src/contract/index.ts | 24 + packages/domain/src/contract/open-dispute.ts | 50 + .../domain/src/contract/release-escrow.ts | 42 + packages/domain/src/entity.ts | 8 + packages/domain/src/index.ts | 100 + .../__tests__/accept-match-request.test.ts | 53 + .../__tests__/create-match-request.test.ts | 164 + .../matching/__tests__/search-stores.test.ts | 65 + .../src/matching/accept-match-request.ts | 38 + .../src/matching/create-match-request.ts | 69 + packages/domain/src/matching/index.ts | 20 + packages/domain/src/matching/search-stores.ts | 24 + .../__tests__/create-store-draft.test.ts | 347 ++ .../__tests__/filter-store-for-viewer.test.ts | 103 + .../src/store/__tests__/publish-store.test.ts | 66 + .../src/store/__tests__/review-store.test.ts | 96 + .../store/__tests__/store-facility.test.ts | 111 + .../src/store/__tests__/store-lease.test.ts | 111 + .../domain/src/store/create-store-draft.ts | 161 + .../src/store/filter-store-for-viewer.ts | 68 + packages/domain/src/store/index.ts | 27 + packages/domain/src/store/publish-store.ts | 43 + packages/domain/src/store/review-store.ts | 49 + packages/domain/src/store/store-facility.ts | 53 + packages/domain/src/store/store-lease.ts | 59 + .../advance-subsidy-to-ready.test.ts | 103 + .../__tests__/create-subsidy-case.test.ts | 115 + .../__tests__/review-subsidy-case.test.ts | 125 + .../src/subsidy/advance-subsidy-to-ready.ts | 60 + .../domain/src/subsidy/create-subsidy-case.ts | 69 + packages/domain/src/subsidy/index.ts | 22 + .../domain/src/subsidy/review-subsidy-case.ts | 55 + packages/domain/src/value-object.ts | 7 + .../apply-vendor-certification.test.ts | 82 + .../approve-vendor-certification.test.ts | 132 + .../filter-vendor-for-search.test.ts | 67 + .../src/vendor/apply-vendor-certification.ts | 61 + .../vendor/approve-vendor-certification.ts | 67 + .../src/vendor/filter-vendor-for-search.ts | 28 + packages/domain/src/vendor/index.ts | 20 + packages/domain/tsconfig.json | 9 + packages/domain/tsup.config.ts | 9 + packages/domain/vitest.config.ts | 8 + packages/infrastructure/package.json | 34 + packages/infrastructure/src/audit-log.ts | 39 + packages/infrastructure/src/event-log.ts | 37 + packages/infrastructure/src/feature-flag.ts | 48 + packages/infrastructure/src/idempotency.ts | 51 + packages/infrastructure/src/index.ts | 9 + packages/infrastructure/src/logger.ts | 9 + packages/infrastructure/src/outbox.ts | 51 + packages/infrastructure/tsconfig.json | 9 + packages/infrastructure/tsup.config.ts | 10 + packages/infrastructure/vitest.config.ts | 8 + packages/shared/package.json | 30 + packages/shared/src/constants.ts | 18 + packages/shared/src/env.ts | 19 + packages/shared/src/index.ts | 5 + packages/shared/src/test-utils/index.ts | 37 + packages/shared/src/types/app-error.ts | 13 + packages/shared/src/types/index.ts | 4 + packages/shared/src/types/result.ts | 21 + packages/shared/tsconfig.json | 9 + packages/shared/tsup.config.ts | 9 + packages/shared/vitest.config.ts | 8 + packages/ui/package.json | 33 + packages/ui/src/button.tsx | 37 + packages/ui/src/index.ts | 6 + packages/ui/tsconfig.json | 11 + packages/ui/tsup.config.ts | 13 + packages/ui/vitest.config.ts | 8 + plan.md | 297 + pnpm-lock.yaml | 5240 +++++++++++++++++ pnpm-workspace.yaml | 3 + tsconfig.base.json | 21 + turbo.json | 26 + vitest.config.ts | 7 + vitest.workspace.ts | 3 + 리링크_예비창업패키지_사업계획서.html | 1504 +++++ 170 files changed, 23628 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 .eslintrc.cjs create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .prettierrc create mode 100644 DEVELOPMENT_PLAN.md create mode 100644 apps/admin/.eslintrc.cjs create mode 100644 apps/admin/next-env.d.ts create mode 100644 apps/admin/next.config.ts create mode 100644 apps/admin/package.json create mode 100644 apps/admin/postcss.config.mjs create mode 100644 apps/admin/src/app/globals.css create mode 100644 apps/admin/src/app/layout.tsx create mode 100644 apps/admin/src/app/page.tsx create mode 100644 apps/admin/tsconfig.json create mode 100644 apps/web/.eslintrc.cjs create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/src/__tests__/integration/contract-service.integration.test.ts create mode 100644 apps/web/src/__tests__/integration/match-request-service.integration.test.ts create mode 100644 apps/web/src/__tests__/integration/store-service.integration.test.ts create mode 100644 apps/web/src/__tests__/integration/subsidy-case-service.integration.test.ts create mode 100644 apps/web/src/__tests__/integration/vendor-certification-service.integration.test.ts create mode 100644 apps/web/src/app/admin/contracts/page.tsx create mode 100644 apps/web/src/app/admin/layout.tsx create mode 100644 apps/web/src/app/admin/page.tsx create mode 100644 apps/web/src/app/admin/stores/page.tsx create mode 100644 apps/web/src/app/admin/subsidies/page.tsx create mode 100644 apps/web/src/app/admin/vendors/page.tsx create mode 100644 apps/web/src/app/api/v1/master-data/route.ts create mode 100644 apps/web/src/app/api/v1/stores/[id]/submit/route.ts create mode 100644 apps/web/src/app/api/v1/stores/route.ts create mode 100644 apps/web/src/app/contracts/page.tsx create mode 100644 apps/web/src/app/globals.css create mode 100644 apps/web/src/app/layout.tsx create mode 100644 apps/web/src/app/matching/page.tsx create mode 100644 apps/web/src/app/page.tsx create mode 100644 apps/web/src/app/stores/[id]/page.tsx create mode 100644 apps/web/src/app/stores/new/page.tsx create mode 100644 apps/web/src/app/stores/page.tsx create mode 100644 apps/web/src/app/subsidies/page.tsx create mode 100644 apps/web/src/app/vendors/page.tsx create mode 100644 apps/web/src/services/contract-service.ts create mode 100644 apps/web/src/services/match-request-service.ts create mode 100644 apps/web/src/services/store-service.ts create mode 100644 apps/web/src/services/subsidy-case-service.ts create mode 100644 apps/web/src/services/vendor-certification-service.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vitest.config.ts create mode 100644 docker-compose.yml create mode 100644 docs/analytics/event-schema.md create mode 100644 docs/database/schema-prisma-draft.md create mode 100644 docs/master-data/beta-master-data.md create mode 100644 docs/policies/contract-escrow-policy.md create mode 100644 docs/policies/data-exposure-policy.md create mode 100644 docs/policies/subsidy-policy.md create mode 100644 package.json create mode 100644 packages/analytics/package.json create mode 100644 packages/analytics/src/event.ts create mode 100644 packages/analytics/src/index.ts create mode 100644 packages/analytics/tsconfig.json create mode 100644 packages/analytics/tsup.config.ts create mode 100644 packages/analytics/vitest.config.ts create mode 100644 packages/application/package.json create mode 100644 packages/application/src/index.ts create mode 100644 packages/application/src/use-case.ts create mode 100644 packages/application/tsconfig.json create mode 100644 packages/application/tsup.config.ts create mode 100644 packages/application/vitest.config.ts create mode 100644 packages/database/.env create mode 100644 packages/database/package.json create mode 100644 packages/database/prisma/schema.prisma create mode 100644 packages/database/seeds/master-data/industries.v1.csv create mode 100644 packages/database/seeds/master-data/regions.v1.csv create mode 100644 packages/database/seeds/seed.ts create mode 100644 packages/database/src/client.ts create mode 100644 packages/database/src/index.ts create mode 100644 packages/database/src/test-helpers.ts create mode 100644 packages/database/tsconfig.json create mode 100644 packages/database/tsup.config.ts create mode 100644 packages/database/vitest.config.ts create mode 100644 packages/domain/package.json create mode 100644 packages/domain/src/contract/__tests__/check-idempotency.test.ts create mode 100644 packages/domain/src/contract/__tests__/create-contract.test.ts create mode 100644 packages/domain/src/contract/__tests__/open-dispute.test.ts create mode 100644 packages/domain/src/contract/__tests__/release-escrow.test.ts create mode 100644 packages/domain/src/contract/check-idempotency.ts create mode 100644 packages/domain/src/contract/create-contract.ts create mode 100644 packages/domain/src/contract/index.ts create mode 100644 packages/domain/src/contract/open-dispute.ts create mode 100644 packages/domain/src/contract/release-escrow.ts create mode 100644 packages/domain/src/entity.ts create mode 100644 packages/domain/src/index.ts create mode 100644 packages/domain/src/matching/__tests__/accept-match-request.test.ts create mode 100644 packages/domain/src/matching/__tests__/create-match-request.test.ts create mode 100644 packages/domain/src/matching/__tests__/search-stores.test.ts create mode 100644 packages/domain/src/matching/accept-match-request.ts create mode 100644 packages/domain/src/matching/create-match-request.ts create mode 100644 packages/domain/src/matching/index.ts create mode 100644 packages/domain/src/matching/search-stores.ts create mode 100644 packages/domain/src/store/__tests__/create-store-draft.test.ts create mode 100644 packages/domain/src/store/__tests__/filter-store-for-viewer.test.ts create mode 100644 packages/domain/src/store/__tests__/publish-store.test.ts create mode 100644 packages/domain/src/store/__tests__/review-store.test.ts create mode 100644 packages/domain/src/store/__tests__/store-facility.test.ts create mode 100644 packages/domain/src/store/__tests__/store-lease.test.ts create mode 100644 packages/domain/src/store/create-store-draft.ts create mode 100644 packages/domain/src/store/filter-store-for-viewer.ts create mode 100644 packages/domain/src/store/index.ts create mode 100644 packages/domain/src/store/publish-store.ts create mode 100644 packages/domain/src/store/review-store.ts create mode 100644 packages/domain/src/store/store-facility.ts create mode 100644 packages/domain/src/store/store-lease.ts create mode 100644 packages/domain/src/subsidy/__tests__/advance-subsidy-to-ready.test.ts create mode 100644 packages/domain/src/subsidy/__tests__/create-subsidy-case.test.ts create mode 100644 packages/domain/src/subsidy/__tests__/review-subsidy-case.test.ts create mode 100644 packages/domain/src/subsidy/advance-subsidy-to-ready.ts create mode 100644 packages/domain/src/subsidy/create-subsidy-case.ts create mode 100644 packages/domain/src/subsidy/index.ts create mode 100644 packages/domain/src/subsidy/review-subsidy-case.ts create mode 100644 packages/domain/src/value-object.ts create mode 100644 packages/domain/src/vendor/__tests__/apply-vendor-certification.test.ts create mode 100644 packages/domain/src/vendor/__tests__/approve-vendor-certification.test.ts create mode 100644 packages/domain/src/vendor/__tests__/filter-vendor-for-search.test.ts create mode 100644 packages/domain/src/vendor/apply-vendor-certification.ts create mode 100644 packages/domain/src/vendor/approve-vendor-certification.ts create mode 100644 packages/domain/src/vendor/filter-vendor-for-search.ts create mode 100644 packages/domain/src/vendor/index.ts create mode 100644 packages/domain/tsconfig.json create mode 100644 packages/domain/tsup.config.ts create mode 100644 packages/domain/vitest.config.ts create mode 100644 packages/infrastructure/package.json create mode 100644 packages/infrastructure/src/audit-log.ts create mode 100644 packages/infrastructure/src/event-log.ts create mode 100644 packages/infrastructure/src/feature-flag.ts create mode 100644 packages/infrastructure/src/idempotency.ts create mode 100644 packages/infrastructure/src/index.ts create mode 100644 packages/infrastructure/src/logger.ts create mode 100644 packages/infrastructure/src/outbox.ts create mode 100644 packages/infrastructure/tsconfig.json create mode 100644 packages/infrastructure/tsup.config.ts create mode 100644 packages/infrastructure/vitest.config.ts create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/constants.ts create mode 100644 packages/shared/src/env.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/test-utils/index.ts create mode 100644 packages/shared/src/types/app-error.ts create mode 100644 packages/shared/src/types/index.ts create mode 100644 packages/shared/src/types/result.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/shared/tsup.config.ts create mode 100644 packages/shared/vitest.config.ts create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/button.tsx create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/tsup.config.ts create mode 100644 packages/ui/vitest.config.ts create mode 100644 plan.md create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 turbo.json create mode 100644 vitest.config.ts create mode 100644 vitest.workspace.ts create mode 100644 리링크_예비창업패키지_사업계획서.html diff --git a/.env b/.env new file mode 100644 index 0000000..91428de --- /dev/null +++ b/.env @@ -0,0 +1,13 @@ +# Database +DATABASE_URL="postgresql://relink:relink_dev@localhost:5432/relink_dev" +DATABASE_TEST_URL="postgresql://relink:relink_test@localhost:5433/relink_test" + +# Redis +REDIS_URL="redis://localhost:6379" + +# Next.js +NEXT_PUBLIC_APP_URL="http://localhost:3000" +NEXT_PUBLIC_ADMIN_URL="http://localhost:3001" + +# Node +NODE_ENV="development" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..91428de --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Database +DATABASE_URL="postgresql://relink:relink_dev@localhost:5432/relink_dev" +DATABASE_TEST_URL="postgresql://relink:relink_test@localhost:5433/relink_test" + +# Redis +REDIS_URL="redis://localhost:6379" + +# Next.js +NEXT_PUBLIC_APP_URL="http://localhost:3000" +NEXT_PUBLIC_ADMIN_URL="http://localhost:3001" + +# Node +NODE_ENV="development" diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..6bbaa68 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,35 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'import'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:import/typescript', + 'prettier', + ], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/consistent-type-imports': 'error', + 'import/order': [ + 'error', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + 'newlines-between': 'always', + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], + 'import/no-duplicates': 'error', + 'no-console': 'warn', + }, + ignorePatterns: ['dist/', '.next/', 'node_modules/', 'coverage/'], + settings: { + 'import/resolver': { + typescript: true, + }, + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4d6eb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +.next/ +out/ + +# Turborepo +.turbo/ + +# Environment (allow .env for dev defaults, ignore local overrides) +.env.local +.env.*.local + +# Test coverage +coverage/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Prisma +packages/database/prisma/*.db +packages/database/prisma/*.db-journal + +# Debug +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OMC plugin state +.omc/ + +# Misc +*.tsbuildinfo +cursor_billing_chart.html diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..3c15efe --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always" +} diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..cb869a1 --- /dev/null +++ b/DEVELOPMENT_PLAN.md @@ -0,0 +1,541 @@ +# Re:Link (리링크) 실행형 개발계획서 + +> 폐업-철거-인테리어 선순환 통합 매칭 플랫폼 +> 작성일: 2026-03-07 | 기준 문서: 2026년 예비창업패키지 사업계획서 +> 목적: 사업계획서를 실제 제품 개발, 운영, 배포가 가능한 실행 문서로 재구성 + +--- + +## 1. 문서 목적 + +이 문서는 단순 기능 목록이 아니라, Re:Link MVP를 실제로 만들기 위해 먼저 고정해야 할 **정책, 운영, 계측, 아키텍처, 일정**을 정리한 실행 기준 문서다. + +### 1-1. 문서 역할 + +- 사업계획서의 비전과 수익 모델을 제품 요구사항으로 번역한다. +- 개발 전에 애매한 의사결정을 줄이고, 구현 중 재작업을 최소화한다. +- 운영자 개입이 많은 초기 플랫폼의 현실을 반영한다. +- 실제 구현은 별도 `plan.md`에서 TDD 기준으로 관리한다. + +### 1-2. 토스식 의사결정 원칙 + +Re:Link의 MVP 의사결정은 아래 원칙을 기본값으로 삼는다. + +| 원칙 | 적용 방식 | +|------|-----------| +| 정책이 코드보다 먼저 | 정부 지원금, 정보 공개, 정산, 분쟁 규칙을 먼저 문서화하고 그 다음 구현한다. | +| 운영 가능한 MVP 우선 | 완전 자동화보다 운영자 승인형 플로우를 먼저 출시한다. | +| 계측 없는 기능은 출시하지 않음 | 모든 핵심 기능은 이벤트 스키마와 KPI 계산식이 먼저 정의되어야 한다. | +| 외부 연동은 격리 | PG, 알림, 지도, 사업자 인증은 모두 어댑터 계층과 재시도 정책 뒤에 둔다. | +| 작게 출시하고 빠르게 보정 | 베타 지역과 업종을 좁혀 feature flag 기반으로 순차 출시한다. | +| 롤백 가능하게 설계 | 결제, 알림, 문서 처리, 상태 전환은 replay 가능한 로그와 감사 기록을 남긴다. | +| DRI 명확화 | 각 도메인마다 최종 의사결정 책임자를 한 명으로 고정한다. | + +### 1-3. 문서 계층 + +| 문서 | 역할 | +|------|------| +| 사업계획서 HTML | 왜 이 사업을 하는지와 시장/수익 전략을 설명 | +| `DEVELOPMENT_PLAN.md` | 무엇을 어떤 원칙으로 만들지 결정 | +| `plan.md` | 현재 구현 중인 테스트와 작업 순서를 관리 | + +--- + +## 2. 제품 정의 + +### 2-1. 핵심 가치 제안 + +폐업자가 매장 정보를 **1회 등록**하면, 창업자-철거업체-인테리어업체를 **동시 연결**하여 폐업 비용을 줄이고 재창업/인수 기회를 높이는 선순환 플랫폼을 만든다. + +### 2-2. 핵심 사용자 + +| 코드 | 사용자 | 핵심 니즈 | 초기 수익 연결 | +|------|--------|----------|----------------| +| U1 | 폐업자 | 철거비 절감, 시설 처분, 지원금 신청 도움 | 거래 성사, 부가 서비스 | +| U2 | 창업자 | 시설 인수, 인테리어 비용 절감 | 성공 수수료 | +| U3 | 철거업체 | 안정적 수주, 인증, 정산 안정성 | 수수료, 향후 구독 | +| U4 | 인테리어업체 | 선제 영업 정보, 리드 확보 | 향후 구독, 광고 | +| U5 | 운영자/관리자 | 심사, 검수, 중재, 정산, 계측 | 서비스 신뢰 유지 | + +### 2-3. 초기 베타 범위 + +- 지역: 서울 강남권 `역삼/선릉/논현`, 마포권 `홍대/합정/연남` +- 업종: F&B 우선 +- 출시 방식: 운영자 개입형 assisted marketplace +- 목표 사용자: 폐업자 100명, 철거업체 50개, 초기 창업자 리드 확보 + +### 2-4. MVP 성공 기준 + +| 항목 | 기준 | +|------|------| +| 공급 확보 | 검수 가능한 폐업 매장 데이터가 지속 유입된다. | +| 거래 신뢰 | 계약, 검수, 정산, 분쟁 처리의 운영 기준이 흔들리지 않는다. | +| 측정 가능성 | 핵심 KPI가 이벤트 로그만으로 재현 가능하다. | +| 운영 가능성 | 운영 콘솔 없이 수작업 엑셀에 의존하지 않는다. | +| 확장성 | Phase 2에서 B2B 구독과 직거래 장터를 붙일 수 있는 데이터 구조를 확보한다. | + +--- + +## 3. MVP 범위 재정의 + +### 3-1. Phase 1 MVP + +| 우선순위 | 기능 묶음 | MVP 포함 범위 | MVP 제외 범위 | +|---------|-----------|----------------|----------------| +| P0 | 사용자/인증 | 역할 선택 가입, 소셜 로그인, 기본 사업자 확인, 운영자 수동 보정 | 고도화된 KYC 자동화 | +| P0 | 매장 등록 | 매장 기본 정보, 임대/시설 정보, 사진 업로드, 검토 요청, 공개/비공개 전환 | 복수 지점 일괄 등록 | +| P0 | 매칭 | 검색, 필터, 매칭 요청, 운영자 수동 추천, 요청 승인/거절 | 실시간 추천 고도화, 자동 배정 | +| P0 | 정부 지원금 가이드형 대행 | 자격 판별, 서류 체크리스트, 업로드, 운영자 검토, 상태 추적 | 법적 자격 없이 완전 대리 제출 자동화 | +| P0.5 | 운영 백오피스 | 등록 검토, 업체 인증, 지원금 검토, 검수 승인, 정산 보류/해제, 감사 로그 | 고급 BI, 복잡한 권한 위임 체계 | +| P1 | 신뢰 인프라 | 업체 인증, 표준 계약서 템플릿, 서명 증적 저장, 에스크로 결제, 사진 검수, 분쟁 접수 | 완전 자동 정산, 고도화된 분쟁 자동 판정 | +| P1 | 시세 추천 | 규칙 기반 범위 추천, 견적 비교 기준 제공 | ML 기반 가격 예측 | + +### 3-2. Phase 2 + +| 우선순위 | 기능 | 범위 | +|---------|------|------| +| P2 | 폐업 물품 직거래 장터 | 물품 등록, 검색, 예약, 거래 완료 | +| P2 | B2B 폐업 예정지 알림 | 지역/업종별 구독, 정기 결제, 알림 상품화 | +| P2 | 정부 지원금 대행 고도화 | 문서 자동 생성, 운영 자동화 | +| P2 | 스마트 시세 추천 고도화 | ML 모델 전환, 정확도 검증 | +| P2 | 모바일 앱 | React Native 기반 모바일 전환 | + +### 3-3. MVP에서 의도적으로 늦추는 것 + +- 실시간 채팅 대신 `문의 스레드 + 운영자 브릿지`를 먼저 사용한다. +- B2B 구독은 데이터 모델과 정책만 먼저 준비하고 유료화는 Phase 2에서 시작한다. +- 직거래 장터는 MVP 핵심 거래 흐름이 안정화된 뒤 붙인다. + +--- + +## 4. 반드시 고정할 제품/운영 정책 + +### 4-1. 정책 기본값 + +| 정책 | 기본 결정 | +|------|-----------| +| 정부 지원금 신청 대행 | MVP는 `가이드형 + 운영자 검토형`으로 시작한다. 법적 자격 검토 전 완전 대행 자동화는 하지 않는다. | +| 폐업 정보 공개 | 기본값은 비식별/제한 공개다. 상세 주소, 연락처, 민감 정보는 소유자 동의와 권한 검증 후 노출한다. | +| B2B 데이터 판매/알림 | MVP에서는 익명화된 리드성 데이터 구조만 준비한다. 원본 개인 정보 직접 판매는 하지 않는다. | +| 계약/에스크로 | 계약 생성과 결제는 가능하되 정산 해제는 검수 승인 또는 운영자 승인 이후에만 진행한다. | +| 분쟁 처리 | 분쟁 발생 시 자동 정산 해제는 중단되고 운영자 검토 상태로 전환한다. | +| 권리금/임대차 | MVP에서는 권리금 협상과 임대차 법률 행위의 직접 중개를 하지 않는다. 정보 제공과 파트너 연결만 한다. | +| 업체 인증 | 인증 서류, 영업 이력, 서비스 가능 지역, 제재 이력을 기준으로 운영자 승인형으로 관리한다. | +| 개인정보/문서 보관 | 문서 접근은 서명 URL과 감사 로그 기반으로 제한하고, 보존 기간은 법적 검토 결과를 우선 적용한다. | + +### 4-2. 지원금 대행 정책 + +- 서비스 포지션은 `정부 사업 경쟁자`가 아니라 `신청 보조 파트너`다. +- 사용자에게는 체크리스트, 업로드, 진행 상태, 운영자 피드백을 제공한다. +- 운영자는 서류 누락, 반려 사유, 제출 준비 여부를 판정한다. +- 정책/법무 검토 전에는 시스템이 정부 기관을 대리해 자동 제출하는 구조를 만들지 않는다. + +### 4-3. 정보 공개 정책 + +- 폐업 등록 즉시 모든 정보가 공개되지 않는다. +- 창업자에게는 매칭 가능한 핵심 요약 정보를 먼저 보여준다. +- 철거업체/인테리어업체에는 역할과 권한에 맞는 정보만 공개한다. +- B2B 알림 상품은 Phase 2에서 시작하되, 지금부터 `공개 등급`, `권한`, `동의`, `열람 이력` 데이터를 남긴다. + +### 4-4. 계약/정산 정책 + +- 계약은 표준 템플릿 기반으로 생성한다. +- 서명 행위는 증적을 남겨야 하며, 문서 버전과 타임스탬프를 같이 저장한다. +- 에스크로 상태는 결제 성공과 운영 승인, 검수 승인, 분쟁 여부를 기준으로 전이한다. +- 부분 환불, 수동 정산, 보류 해제는 운영 콘솔에서만 실행한다. + +--- + +## 5. 도메인 모델 + +### 5-1. 바운디드 컨텍스트 + +| 컨텍스트 | 책임 | +|----------|------| +| Identity | 사용자, 역할, 인증, 사업자 확인 | +| Store Supply | 매장 등록, 시설/임대 정보, 사진, 공개 상태 | +| Matching | 매칭 후보 계산, 요청, 수락/거절, 운영자 추천 | +| Subsidy | 지원금 체크리스트, 서류, 상태, 운영자 검토 | +| Trust | 업체 인증, 계약, 에스크로, 검수, 분쟁 | +| Notification | 알림톡, 푸시, 이메일, 메시지 템플릿 | +| Backoffice | 운영 큐, 감사 로그, 수동 보정, 통계 | +| Analytics | 이벤트 로그, KPI, 리포트, 데이터 파이프라인 | + +### 5-2. 핵심 엔티티 + +``` +User +├── UserProfile +├── UserConsent +├── Store[] +└── Vendor[] + +Store +├── StoreLease +├── StoreFacility +├── StoreLifecycle +├── StorePhoto[] +├── StoreAsset[] +├── MatchLead[] +├── MatchRequest[] +└── PriceEstimate[] + +Vendor +├── VendorCertification +├── VendorCoverageArea[] +├── VendorQuote[] +└── VendorPenalty[] + +MatchRequest +├── MatchReview +└── Contract? + +Contract +├── ContractVersion[] +├── SignatureEvidence[] +├── EscrowTransaction +├── InspectionRecord[] +└── DisputeCase? + +SubsidyCase +├── SubsidyDocument[] +├── SubsidyChecklistItem[] +└── SubsidyReviewLog[] + +AlertSubscription +AuditLog +EventLog +``` + +### 5-3. 베타에 반드시 필요한 마스터 데이터 + +| 데이터 | 설명 | +|--------|------| +| `RegionHierarchy` | 시/구/동/상권/클러스터 구조 | +| `IndustryTaxonomy` | 대분류/중분류/세분류/영업형태 | +| `StoreLease` | 보증금, 월세, 관리비, 권리금, 잔여 임대기간 | +| `StoreFacility` | 전용면적, 층수, 좌석 수, 전기, 가스, 배수, 덕트, 주방 설비 | +| `StoreLifecycle` | 폐업 예정일, 인수 가능일, 철거 예정일 | +| `VendorCoverageArea` | 업체 서비스 권역 | +| `VendorCapability` | 철거/인테리어 가능 범위, 장비, 인증 상태 | + +### 5-4. 상태머신 + +| 도메인 | 상태 | +|--------|------| +| Store | `DRAFT -> SUBMITTED -> REVIEWING -> PUBLISHED -> MATCHING -> RESERVED -> CONTRACTED -> CLOSED/CANCELLED` | +| MatchRequest | `PENDING -> REVIEWING -> ACCEPTED/REJECTED -> CONTRACTING -> COMPLETED/EXPIRED` | +| SubsidyCase | `DRAFT -> ELIGIBILITY_CHECKED -> DOCUMENTS_PENDING -> REVIEWING -> READY_TO_SUBMIT -> SUBMITTED -> APPROVED/REJECTED` | +| VendorCertification | `APPLIED -> REVIEWING -> APPROVED/REJECTED -> SUSPENDED` | +| Contract | `DRAFT -> GENERATED -> SIGNING -> SIGNED -> IN_PROGRESS -> COMPLETED/CANCELLED` | +| Escrow | `PENDING -> DEPOSIT_PAID -> HOLDING -> RELEASE_REVIEW -> RELEASED/REFUNDED` 또는 `HOLDING/RELEASE_REVIEW -> DISPUTED` | +| Inspection | `REQUESTED -> UPLOADED -> REVIEWING -> APPROVED/REJECTED` | +| DisputeCase | `OPEN -> INVESTIGATING -> MEDIATING -> RESOLVED/CLOSED` | + +모든 상태 전환은 `AuditLog`와 `EventLog`를 동시에 남긴다. + +--- + +## 6. 운영 백오피스 설계 + +### 6-1. MVP 필수 운영 화면 + +- 매장 등록 검토 큐 +- 공개/비공개 전환 및 반려 사유 관리 +- 업체 인증 심사 큐 +- 지원금 서류 검토 큐 +- 계약 문서/서명 증적 뷰어 +- 검수 사진 승인 화면 +- 분쟁 접수/메모/상태 변경 화면 +- 정산 보류/해제 및 결제 대사 화면 +- 이벤트/KPI 대시보드 +- 감사 로그 조회 + +### 6-2. 운영 역할 + +| 역할 | 책임 | +|------|------| +| `SUPER_ADMIN` | 전체 설정, 권한, 긴급 조치 | +| `OPS_MANAGER` | 등록 검토, 매칭 보정, 운영 품질 | +| `SUBSIDY_OPERATOR` | 지원금 서류 검토, 상태 관리 | +| `TRUST_OPERATOR` | 업체 인증, 계약, 검수, 분쟁 | +| `FINANCE_OPERATOR` | 정산, 결제 대사, 환불 처리 | + +### 6-3. 운영 기본 원칙 + +- 운영자가 개입한 모든 결정에는 사유 코드와 메모를 남긴다. +- 사람이 개입해 상태를 바꾼 경우 원래 상태와 변경자를 감사 로그에 남긴다. +- 베타 단계에서는 자동화보다 운영 안정성이 우선이다. + +--- + +## 7. 아키텍처와 인프라 + +### 7-1. 아키텍처 방향 + +Phase 1은 **Next.js 기반 모듈러 모놀리스**로 시작하되, 도메인 로직은 프레임워크 바깥 계층에 두어 Phase 2의 분리를 `재작성`이 아닌 `어댑터 추가`로 만든다. + +### 7-2. 추천 프로젝트 구조 + +``` +re-link/ +├── apps/ +│ ├── web/ # Next.js 사용자 웹 +│ ├── admin/ # Next.js 운영 콘솔 +│ └── api/ # Phase 2 NestJS 어댑터 +├── packages/ +│ ├── domain/ # 엔티티, 값 객체, 상태 규칙 +│ ├── application/ # 유스케이스, 서비스, 정책 +│ ├── infrastructure/ # PG/알림/지도/파일/결제 어댑터 +│ ├── database/ # Prisma 스키마 + 마이그레이션 +│ ├── analytics/ # 이벤트 스키마, KPI 계산 +│ ├── ui/ # 공통 UI +│ └── shared/ # 공통 타입, 상수, 유틸 +└── docs/ + └── policies/ # 제품/운영 정책 문서 +``` + +### 7-3. 기술 선택 + +| 레이어 | 선택 | 원칙 | +|--------|------|------| +| 프론트엔드 | Next.js 15 + TypeScript | 웹/운영 콘솔 동시 대응 | +| 상태 관리 | Zustand + TanStack Query v5 | 클라이언트/서버 상태 분리 | +| UI | shadcn/ui + Tailwind CSS v4 | 빠른 조립과 일관성 | +| 백엔드 | Next.js Route Handler + Service Layer | HTTP 어댑터를 얇게 유지 | +| ORM | Prisma | 타입 안정성과 마이그레이션 | +| DB | PostgreSQL 16 + PostGIS | 지역 기반 검색과 관계 모델 | +| 캐시 | Redis | 캐시, 세션, 읽기 성능 개선 | +| 비동기 처리 | Postgres Outbox + Worker | MVP에서 신뢰성 있는 재시도와 idempotency 확보 | +| 파일 | 객체 스토리지 인터페이스 | IDC 환경에서도 MinIO/S3 호환 구조로 추상화 | +| 결제 | 토스페이먼츠 | 국내 PG/에스크로 연동 | +| 인증 | Auth.js 계열 + 소셜 로그인 | 접근성 확보 | +| 지도 | Kakao Maps SDK + 로컬 API | 국내 주소/좌표 정확도 | +| 알림 | 카카오 알림톡 + FCM + SES | 템플릿형 커뮤니케이션 | +| 관측 | Sentry + 구조화 로그 + 대시보드 | 운영 중심 모니터링 | + +### 7-4. 외부 연동 규칙 + +| 연동 | MVP 원칙 | +|------|-----------| +| 토스페이먼츠 | 웹훅은 idempotent 하게 처리하고 replay 기능을 제공한다. | +| 알림 | 템플릿 ID를 관리하고 발송 실패 시 재시도한다. | +| 지도 | 주소 정규화와 좌표 캐시를 둔다. | +| 사업자 확인 | 자동 조회 실패 시 운영자 수동 승인 절차를 둔다. | +| 파일 업로드 | 서명 URL, 접근 로그, 업로드 용량 제한을 적용한다. | + +### 7-5. 배포 원칙 + +- PR 단계에서 lint, type-check, unit test를 통과해야 한다. +- staging에서 integration test와 smoke test를 통과해야 production 배포가 가능하다. +- 결제, 정산, 분쟁 관련 기능은 feature flag 뒤에서 점진 출시한다. + +--- + +## 8. 보안 및 컴플라이언스 + +| 영역 | 대책 | +|------|------| +| 인증 | 세션/토큰 만료 정책, 소셜 로그인, 운영자 계정 보호 | +| 인가 | RBAC + API 단위 권한 검사 | +| 개인정보 | PII 분리 저장, 민감 필드 암호화, 접근 로그 | +| 결제 | 카드 정보 비저장, PG 위임, 정산 로그 보존 | +| 문서 | 서명 URL 기반 접근, 버전 보존, 다운로드 로그 | +| API | Rate limiting, CORS, CSRF, 입력 검증 | +| 인프라 | VPC, 프라이빗 DB, 보안 그룹, 비밀값 관리 | +| 감사 | 운영자 조작 로그, 상태 변경 이력, 이벤트 추적 | + +--- + +## 9. 데이터 계측과 KPI 정의 + +### 9-1. KPI 소스 오브 트루스 + +| KPI | 정의 | 소스 이벤트 | +|-----|------|-------------| +| 등록 폐업자 | `store_submitted`를 1회 이상 발생시킨 고유 폐업자 수 | `store_submitted` | +| 입점 업체 수 | 인증 승인된 업체 수 | `vendor_certification_approved` | +| 월 거래 건수 | 인수 완료 또는 철거 정산 완료된 거래 수 | `transaction_completed` | +| MRR | 월 인식 반복 매출 합계 | `revenue_recognized_monthly` | +| B2B 구독 업체 | 유료 구독이 활성 상태인 업체 수 | `subscription_activated` | +| 시설인수 매칭 성공률 | 인수 대상 매장 중 계약 완료된 매장 비율 | `store_published`, `acquisition_contract_signed` | +| 유지율 | 기준 기간 내 재방문 또는 재거래한 사업자 비율 | `monthly_active_user`, `transaction_completed` | + +### 9-2. MVP 필수 이벤트 + +| 이벤트명 | 필수 속성 | +|----------|-----------| +| `store_draft_created` | `user_id`, `region_cluster`, `industry_code` | +| `store_submitted` | `store_id`, `user_id`, `region_cluster`, `industry_code` | +| `store_reviewed` | `store_id`, `result`, `reason_code`, `operator_id` | +| `store_published` | `store_id`, `region_cluster`, `industry_code`, `publish_channel` | +| `match_requested` | `store_id`, `requester_role`, `requester_id` | +| `match_accepted` | `store_id`, `match_request_id`, `operator_involved` | +| `subsidy_case_started` | `subsidy_case_id`, `store_id`, `eligibility_result` | +| `subsidy_case_reviewed` | `subsidy_case_id`, `result`, `reason_code`, `operator_id` | +| `vendor_certification_applied` | `vendor_id`, `service_type`, `coverage_area` | +| `vendor_certification_approved` | `vendor_id`, `operator_id` | +| `contract_generated` | `contract_id`, `store_id`, `contract_type` | +| `acquisition_contract_signed` | `contract_id`, `store_id`, `industry_code`, `region_cluster` | +| `contract_signed` | `contract_id`, `signed_by_role`, `document_version` | +| `escrow_paid` | `escrow_id`, `amount`, `payment_provider` | +| `inspection_submitted` | `inspection_id`, `contract_id`, `photo_count` | +| `inspection_approved` | `inspection_id`, `operator_id` | +| `dispute_opened` | `dispute_id`, `contract_id`, `reason_code` | +| `escrow_released` | `escrow_id`, `amount`, `release_type` | +| `transaction_completed` | `transaction_type`, `store_id`, `contract_id`, `amount` | +| `revenue_recognized_monthly` | `revenue_type`, `amount`, `recognized_month` | +| `subscription_activated` | `vendor_id`, `plan_code`, `billing_cycle` | +| `monthly_active_user` | `user_id`, `user_role`, `activity_month` | +| `notification_sent` | `channel`, `template_code`, `target_role` | + +### 9-3. 계측 원칙 + +- 이벤트 스키마에는 버전을 둔다. +- PII는 analytics 이벤트에 직접 싣지 않는다. +- 베타 이전에 운영자가 KPI를 직접 확인할 수 있는 대시보드를 연다. +- 계산식이 정의되지 않은 지표는 KPI로 선언하지 않는다. + +--- + +## 10. 테스트 및 품질 기준 + +### 10-1. 개발 방식 + +- 모든 기능은 `Red -> Green -> Refactor`의 TDD 사이클로 구현한다. +- 개발 시작 전에 별도 `plan.md`를 만들고 테스트 단위를 먼저 쪼갠다. +- 구조적 변경과 행위적 변경은 같은 커밋에 섞지 않는다. + +### 10-2. 테스트 레이어 + +| 레이어 | 범위 | +|--------|------| +| Unit Test | 정책 함수, 상태머신, 가격 규칙, 권한 규칙 | +| Integration Test | API, DB, PG 웹훅, 알림 어댑터, 파일 업로드 | +| E2E Test | 매장 등록, 매칭 요청, 지원금 서류 제출, 계약/검수/정산 흐름 | + +### 10-3. 베타 전 필수 시나리오 + +- 폐업자가 매장을 등록하고 운영자 검토 후 공개할 수 있다. +- 창업자가 공개 매장을 검색하고 매칭 요청을 보낼 수 있다. +- 운영자가 지원금 서류를 검토하고 상태를 변경할 수 있다. +- 업체 인증 승인 후 계약 생성과 결제가 진행된다. +- 검수 승인 전에는 정산이 해제되지 않는다. +- 분쟁이 열리면 정산이 자동으로 보류된다. + +--- + +## 11. 게이트 기반 일정 + +### 11-1. 전제 조건 + +- 26.04 협약 시작 +- 대표 1명 + CTO 1명 즉시 투입 +- 26.05 백엔드 1명, 디자이너 1명 합류 +- 기존 프로토타입 코드와 운영 방식이 존재함 + +### 11-2. 게이트 정의 + +| 게이트 | 종료 조건 | +|--------|-----------| +| G0 정책 고정 | 지원금, 정보 공개, 정산/분쟁 정책 문서 확정 | +| G1 공급 유입 준비 | 회원, 매장 등록, 검토 큐, 지역/업종 마스터 완료 | +| G2 Assisted Matching 준비 | 공개/검색/요청/수동 추천/알림까지 동작 | +| G3 Trust 인프라 준비 | 업체 인증, 계약, 결제, 검수, 분쟁 흐름 검증 | +| G4 Beta Ready | 계측, 대시보드, 보안, 관제, 운영 런북 완료 | + +### 11-3. 스프린트 계획 + +| 스프린트 | 기간 | 목표 | 산출물 | +|---------|------|------|--------| +| S1 | 04.01~04.14 | G0 준비 | 프로토타입 리뷰, 정책 초안, 도메인 모델, 이벤트 스키마, 인프라 초안 | +| S2 | 04.15~04.28 | G0 완료 | 정책 문서 확정, 운영 권한 모델, 백오피스 IA, CI/CD | +| S3 | 04.29~05.12 | G1 시작 | 회원/인증, 지역/업종 마스터, 매장 등록, 검토 큐 | +| S4 | 05.13~05.26 | G1 완료 | 매장 공개, 기본 검색, 운영자 검토, 사진 업로드 | +| S5 | 05.27~06.09 | G2 | 매칭 요청/수락, 수동 추천, 문의 스레드, 알림 | +| S6 | 06.10~06.23 | G2 확장 | 지원금 가이드형 대행, 체크리스트, 운영자 리뷰 | +| S7 | 06.24~07.07 | G3 시작 | 업체 등록/인증, 계약서 템플릿, 서명 증적 | +| S8 | 07.08~07.21 | G3 완료 | 에스크로 샌드박스, 검수, 분쟁, 정산 보류/해제 | +| S9 | 07.22~08.04 | G4 시작 | KPI 대시보드, 감사 로그, 장애 알람, 런북 | +| S10 | 08.05~08.18 | G4 완료 | 통합 테스트, E2E, 보안 점검, 운영 온보딩 | +| S11 | 08.19~09.01 | 베타 런칭 | 강남/마포 베타, 모니터링, 운영 데이터 수집 | +| S12~S16 | 09.02~12.31 | Phase 2 준비 | 직거래 장터, B2B 알림, 시세 고도화, 모바일 전환 준비 | + +### 11-4. 우선순위 재정렬 원칙 + +- 정책과 운영이 고정되지 않으면 자동화 기능을 미룬다. +- 정산과 분쟁이 불안정하면 채팅이나 고급 UX보다 먼저 보완한다. +- 베타 이전에는 `운영 도구`와 `계측`이 신규 기능보다 우선이다. + +### 11-5. MVP DRI + +| 영역 | DRI | +|------|-----| +| 제품 정책/우선순위 | 대표 | +| 아키텍처/배포/품질 | CTO | +| 결제/정산/외부 연동 | 백엔드 개발 | +| 운영 프로세스/백오피스 | 대표 + 운영 담당 | +| UX/플로우 설계 | UI/UX 디자이너 | + +--- + +## 12. 외부 의존성과 리스크 + +### 12-1. 외부 의존성 + +| 영역 | 파트너 | 착수 시점 | 실패 시 fallback | +|------|--------|-----------|------------------| +| 결제/에스크로 | 토스페이먼츠 | S1 | 수동 정산 승인형 운영 | +| 계약/법무 | 법률사무소 | S1~S2 | 계약 템플릿은 법무 승인 전 제한 사용 | +| 정부 지원 연계 | 소상공인 지원 기관 | S2~S3 | 가이드형 서비스로 축소 | +| 초기 공급 확보 | 지역 철거업체 협회/파트너 | S3~S4 | 운영자 직접 온보딩 | +| 인프라 | AWS/IDC | S1 | 환경 이중화와 저장소 추상화 | + +### 12-2. 핵심 리스크 + +| 등급 | 리스크 | 대응 | +|------|--------|------| +| CRITICAL | 에스크로 계약/연동 지연 | S1 즉시 착수, 웹훅/정산 구조 선구현, 운영 fallback 준비 | +| CRITICAL | 지원금 대행의 법적 경계 불명확 | 가이드형 MVP 유지, 법무 검토 후 범위 확장 | +| HIGH | 폐업 정보 확보 부족 | 지원금 유입 채널, 협회/기관 제휴, 운영자 소싱 병행 | +| HIGH | 분쟁/정산 운영 과부하 | 백오피스와 사유 코드 체계 선구축 | +| HIGH | 지역/업종 데이터 품질 부족 | 강남/마포, F&B로 범위 축소 후 데이터 정제 | +| MEDIUM | 실시간 기능 복잡도 | 문의 스레드 우선, 실시간 채팅은 뒤로 이동 | +| MEDIUM | 파일 저장 안정성 | 객체 스토리지 추상화, 접근 제어, 백업 정책 적용 | + +--- + +## 13. 개발 착수 전 즉시 해야 할 일 + +### 13-1. 반드시 이번 주에 끝낼 것 + +1. 프로토타입 코드와 현재 운영 방식 실사 +2. `지원금 대행 정책서` 작성 +3. `폐업 정보 공개 정책서` 작성 +4. `계약-에스크로-분쟁 정책서` 작성 +5. KPI 계산식과 이벤트 스키마 고정 +6. 베타 지역/업종 마스터 데이터 정의 + +### 13-2. 바로 만들어야 할 산출물 + +- `plan.md` +- `docs/policies/subsidy-policy.md` +- `docs/policies/data-exposure-policy.md` +- `docs/policies/contract-escrow-policy.md` +- `docs/analytics/event-schema.md` +- `docs/ops/runbook.md` + +--- + +## 14. 최종 정리 + +Re:Link의 MVP는 단순한 매칭 앱이 아니라, **정책과 운영으로 신뢰를 만드는 거래 플랫폼**이다. 따라서 개발 우선순위는 `예쁜 UI`나 `기능 수`가 아니라 아래 순서를 따라야 한다. + +1. 정책 고정 +2. 운영 도구 구축 +3. 계측 삽입 +4. 핵심 거래 흐름 구현 +5. 베타 검증 +6. 이후 자동화와 확장 + +이 순서를 지키면 사업계획서의 강점인 `정부 연계`, `신뢰 인프라`, `데이터 자산화`가 코드 구조와 운영 구조 안에서 살아난다. diff --git a/apps/admin/.eslintrc.cjs b/apps/admin/.eslintrc.cjs new file mode 100644 index 0000000..34dff4a --- /dev/null +++ b/apps/admin/.eslintrc.cjs @@ -0,0 +1,5 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + extends: ['next/core-web-vitals'], +}; diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts new file mode 100644 index 0000000..d4b523b --- /dev/null +++ b/apps/admin/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + transpilePackages: ['@relink/ui', '@relink/shared'], +}; + +export default nextConfig; diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..a235a78 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,32 @@ +{ + "name": "@relink/admin", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start --port 3001", + "lint": "next lint", + "type-check": "tsc --noEmit", + "clean": "rm -rf .next" + }, + "dependencies": { + "@relink/shared": "workspace:*", + "@relink/ui": "workspace:*", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "eslint": "^8.57.1", + "eslint-config-next": "^15.1.0", + "@types/node": "^22.10.2", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "postcss": "^8.4.49", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2" + } +} diff --git a/apps/admin/postcss.config.mjs b/apps/admin/postcss.config.mjs new file mode 100644 index 0000000..5d6d845 --- /dev/null +++ b/apps/admin/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/apps/admin/src/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx new file mode 100644 index 0000000..5a9dc3d --- /dev/null +++ b/apps/admin/src/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; + +import './globals.css'; + +export const metadata: Metadata = { + title: 'Re:Link Admin', + description: 'Re:Link 운영 콘솔', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx new file mode 100644 index 0000000..cf44fd4 --- /dev/null +++ b/apps/admin/src/app/page.tsx @@ -0,0 +1,8 @@ +export default function AdminHomePage() { + return ( +
+

Re:Link Admin

+

운영 콘솔

+
+ ); +} diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..491dd56 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2017", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "preserve", + "module": "ESNext", + "moduleResolution": "bundler", + "allowJs": true, + "noEmit": true, + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + }, + "verbatimModuleSyntax": false + }, + "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs new file mode 100644 index 0000000..34dff4a --- /dev/null +++ b/apps/web/.eslintrc.cjs @@ -0,0 +1,5 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + extends: ['next/core-web-vitals'], +}; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..d4b523b --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + transpilePackages: ['@relink/ui', '@relink/shared'], +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..06ffa63 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "@relink/web", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf .next" + }, + "dependencies": { + "@prisma/client": "^6.1.0", + "@relink/database": "workspace:*", + "@relink/domain": "workspace:*", + "@relink/infrastructure": "workspace:*", + "@relink/shared": "workspace:*", + "@relink/ui": "workspace:*", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "eslint": "^8.57.1", + "eslint-config-next": "^15.1.0", + "@types/node": "^22.10.2", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "postcss": "^8.4.49", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..5d6d845 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/apps/web/src/__tests__/integration/contract-service.integration.test.ts b/apps/web/src/__tests__/integration/contract-service.integration.test.ts new file mode 100644 index 0000000..276365b --- /dev/null +++ b/apps/web/src/__tests__/integration/contract-service.integration.test.ts @@ -0,0 +1,721 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { + setupTestDatabase, + teardownTestDatabase, + cleanAllTables, + seedTestMasterData, +} from '@relink/database'; +import { + createStoreDraftService, + submitStoreService, + reviewStoreService, + publishStoreService, +} from '../../services/store-service'; +import { + createMatchRequestService, + acceptMatchRequestService, +} from '../../services/match-request-service'; +import { + createContractService, + releaseEscrowService, + processEscrowWebhookService, + openDisputeService, +} from '../../services/contract-service'; + +describe('Contract Service Integration Tests', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDatabase(); + }); + + afterAll(async () => { + await teardownTestDatabase(); + }); + + beforeEach(async () => { + await cleanAllTables(prisma); + await seedTestMasterData(prisma); + }); + + async function createTestUser(overrides: Record = {}) { + return prisma.user.create({ + data: { + email: 'owner@example.com', + emailNormalized: 'owner@example.com', + name: '매장 소유자', + primaryRole: 'CLOSING_OWNER', + status: 'ACTIVE', + ...overrides, + }, + }); + } + + async function createOperator(overrides: Record = {}) { + return prisma.user.create({ + data: { + email: 'ops@example.com', + emailNormalized: 'ops@example.com', + name: '운영자', + primaryRole: 'OPS_MANAGER', + status: 'ACTIVE', + ...overrides, + }, + }); + } + + async function createPublishedStore(ownerUserId: bigint, operatorUserId: bigint) { + const createResult = await createStoreDraftService(prisma, { + ownerUserId: ownerUserId.toString(), + listingTitle: '강남역 카페 양도', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 테헤란로 123', + }); + if (!createResult.ok) throw new Error('createStoreDraft failed'); + + await submitStoreService(prisma, createResult.value.publicId, ownerUserId.toString()); + await reviewStoreService( + prisma, + createResult.value.publicId, + 'APPROVED', + operatorUserId.toString(), + ); + + const policyVersion = await prisma.policyVersion.create({ + data: { + policyType: 'STORE_LISTING_POLICY', + versionCode: 'v1.0', + contentHash: 'sha256-test-hash', + effectiveFrom: new Date(), + }, + }); + + await publishStoreService( + prisma, + createResult.value.publicId, + policyVersion.id.toString(), + operatorUserId.toString(), + ); + + return createResult.value.publicId; + } + + async function createAcceptedMatchRequest( + storePublicId: string, + requesterUserId: bigint, + operatorUserId: bigint, + ) { + const createResult = await createMatchRequestService(prisma, { + storePublicId, + matchType: 'ACQUISITION', + sourceType: 'USER_REQUEST', + requesterUserId: requesterUserId.toString(), + message: '매장 인수 희망', + }); + if (!createResult.ok) throw new Error('createMatchRequest failed'); + + const acceptResult = await acceptMatchRequestService( + prisma, + createResult.value.publicId, + operatorUserId.toString(), + ); + if (!acceptResult.ok) throw new Error('acceptMatchRequest failed'); + + return createResult.value.publicId; + } + + async function createContractPolicyVersion() { + return prisma.policyVersion.create({ + data: { + policyType: 'CONTRACT_TEMPLATE', + versionCode: 'ct-v1.0', + contentHash: 'sha256-contract-template', + effectiveFrom: new Date(), + }, + }); + } + + // --------------------------------------------------------------------------- + // I012: POST /api/v1/contracts - 계약 생성 + // --------------------------------------------------------------------------- + + describe('I012: createContractService', () => { + it('ACCEPTED 매칭에서 DRAFT 계약이 생성되고 AuditLog/OutboxEvent 기록', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업 희망자', + primaryRole: 'FOUNDER', + }); + const storePublicId = await createPublishedStore(owner.id, operator.id); + const matchPublicId = await createAcceptedMatchRequest( + storePublicId, + requester.id, + operator.id, + ); + const contractPolicy = await createContractPolicyVersion(); + + const result = await createContractService(prisma, { + matchRequestPublicId: matchPublicId, + contractType: 'ACQUISITION', + templateCode: 'ACQ_STANDARD_V1', + policyVersionId: contractPolicy.id.toString(), + createdByUserId: operator.id.toString(), + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.status).toBe('DRAFT'); + expect(result.value.escrowStatus).toBe('NOT_STARTED'); + expect(result.value.publicId).toBeTruthy(); + + // DB 저장 확인 + const contract = await prisma.contract.findUnique({ + where: { publicId: result.value.publicId }, + }); + expect(contract).not.toBeNull(); + expect(contract!.contractType).toBe('ACQUISITION'); + expect(contract!.templateCode).toBe('ACQ_STANDARD_V1'); + expect(contract!.policyVersionId).toBe(contractPolicy.id); + expect(contract!.storeOwnerUserId).toBe(owner.id); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: result.value.publicId, actionType: 'CONTRACT_CREATED' }, + }); + expect(auditLogs).toHaveLength(1); + + // OutboxEvent 확인 + const outboxEvents = await prisma.outboxEvent.findMany({ + where: { aggregateId: result.value.publicId, eventName: 'contract.created' }, + }); + expect(outboxEvents).toHaveLength(1); + }); + + it('OPEN 매칭에서는 계약 생성 불가', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업 희망자', + primaryRole: 'FOUNDER', + }); + const storePublicId = await createPublishedStore(owner.id, operator.id); + + // OPEN 상태 매칭 (수락하지 않음) + const matchResult = await createMatchRequestService(prisma, { + storePublicId, + matchType: 'ACQUISITION', + sourceType: 'USER_REQUEST', + requesterUserId: requester.id.toString(), + }); + if (!matchResult.ok) throw new Error('createMatchRequest failed'); + + const contractPolicy = await createContractPolicyVersion(); + + const result = await createContractService(prisma, { + matchRequestPublicId: matchResult.value.publicId, + contractType: 'ACQUISITION', + templateCode: 'ACQ_STANDARD_V1', + policyVersionId: contractPolicy.id.toString(), + createdByUserId: operator.id.toString(), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('MATCH_NOT_ACCEPTED'); + }); + + it('존재하지 않는 매칭 요청으로는 계약 생성 불가', async () => { + const operator = await createOperator(); + const contractPolicy = await createContractPolicyVersion(); + + const result = await createContractService(prisma, { + matchRequestPublicId: 'non-existent', + contractType: 'ACQUISITION', + templateCode: 'ACQ_STANDARD_V1', + policyVersionId: contractPolicy.id.toString(), + createdByUserId: operator.id.toString(), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); + + it('존재하지 않는 정책 버전으로는 계약 생성 불가', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업 희망자', + primaryRole: 'FOUNDER', + }); + const storePublicId = await createPublishedStore(owner.id, operator.id); + const matchPublicId = await createAcceptedMatchRequest( + storePublicId, + requester.id, + operator.id, + ); + + const result = await createContractService(prisma, { + matchRequestPublicId: matchPublicId, + contractType: 'ACQUISITION', + templateCode: 'ACQ_STANDARD_V1', + policyVersionId: '99999', + createdByUserId: operator.id.toString(), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); + }); + + // --------------------------------------------------------------------------- + // I013: 에스크로 정산 해제 + // --------------------------------------------------------------------------- + + describe('I013: releaseEscrowService', () => { + async function createActiveContractWithHoldingEscrow( + owner: { id: bigint }, + operator: { id: bigint }, + requester: { id: bigint }, + ) { + const storePublicId = await createPublishedStore(owner.id, operator.id); + const matchPublicId = await createAcceptedMatchRequest( + storePublicId, + requester.id, + operator.id, + ); + const contractPolicy = await createContractPolicyVersion(); + + const contractResult = await createContractService(prisma, { + matchRequestPublicId: matchPublicId, + contractType: 'ACQUISITION', + templateCode: 'ACQ_STANDARD_V1', + policyVersionId: contractPolicy.id.toString(), + createdByUserId: operator.id.toString(), + }); + if (!contractResult.ok) throw new Error('createContract failed'); + + // 계약 상태를 ACTIVE로, 에스크로를 HOLDING으로 변경 + const contract = await prisma.contract.findUnique({ + where: { publicId: contractResult.value.publicId }, + }); + await prisma.contract.update({ + where: { id: contract!.id }, + data: { status: 'ACTIVE', escrowStatus: 'HOLDING' }, + }); + + return contractResult.value.publicId; + } + + it('검수 승인 완료 시 에스크로가 RELEASE_REVIEW로 전환되고 감사 로그 기록', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업자', + primaryRole: 'FOUNDER', + }); + + const contractPublicId = await createActiveContractWithHoldingEscrow( + owner, + operator, + requester, + ); + + // 검수 승인 레코드 생성 + const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } }); + await prisma.inspectionRecord.create({ + data: { + contractId: contract!.id, + inspectionType: 'FINAL_COMPLETION', + status: 'APPROVED', + reviewedAt: new Date(), + }, + }); + + const result = await releaseEscrowService(prisma, contractPublicId, operator.id.toString()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.escrowStatus).toBe('RELEASE_REVIEW'); + + // DB 확인 + const updated = await prisma.contract.findUnique({ where: { publicId: contractPublicId } }); + expect(updated!.escrowStatus).toBe('RELEASE_REVIEW'); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: contractPublicId, actionType: 'ESCROW_RELEASE_REQUESTED' }, + }); + expect(auditLogs).toHaveLength(1); + expect((auditLogs[0]!.beforeJson as Record)?.escrowStatus).toBe('HOLDING'); + expect((auditLogs[0]!.afterJson as Record)?.escrowStatus).toBe( + 'RELEASE_REVIEW', + ); + + // OutboxEvent 확인 + const outboxEvents = await prisma.outboxEvent.findMany({ + where: { aggregateId: contractPublicId, eventName: 'escrow.release_requested' }, + }); + expect(outboxEvents).toHaveLength(1); + }); + + it('검수 미승인 시 정산 해제 불가', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업자', + primaryRole: 'FOUNDER', + }); + + const contractPublicId = await createActiveContractWithHoldingEscrow( + owner, + operator, + requester, + ); + + // 검수 레코드 없음 (미승인) + const result = await releaseEscrowService(prisma, contractPublicId, operator.id.toString()); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('INSPECTION_NOT_APPROVED'); + }); + + it('열린 분쟁이 있으면 정산 해제 차단', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업자', + primaryRole: 'FOUNDER', + }); + + const contractPublicId = await createActiveContractWithHoldingEscrow( + owner, + operator, + requester, + ); + + const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } }); + + // 검수 승인 + 열린 분쟁 생성 + await prisma.inspectionRecord.create({ + data: { + contractId: contract!.id, + inspectionType: 'FINAL_COMPLETION', + status: 'APPROVED', + reviewedAt: new Date(), + }, + }); + await prisma.disputeCase.create({ + data: { + contractId: contract!.id, + openedByUserId: owner.id, + status: 'OPEN', + reasonCode: 'QUALITY_ISSUE', + }, + }); + + const result = await releaseEscrowService(prisma, contractPublicId, operator.id.toString()); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('DISPUTE_OPEN'); + }); + }); + + // --------------------------------------------------------------------------- + // I014: 에스크로 웹훅 멱등 처리 + // --------------------------------------------------------------------------- + + describe('I014: processEscrowWebhookService', () => { + async function createContractForWebhook( + owner: { id: bigint }, + operator: { id: bigint }, + requester: { id: bigint }, + ) { + const storePublicId = await createPublishedStore(owner.id, operator.id); + const matchPublicId = await createAcceptedMatchRequest( + storePublicId, + requester.id, + operator.id, + ); + const contractPolicy = await createContractPolicyVersion(); + + const contractResult = await createContractService(prisma, { + matchRequestPublicId: matchPublicId, + contractType: 'ACQUISITION', + templateCode: 'ACQ_STANDARD_V1', + policyVersionId: contractPolicy.id.toString(), + createdByUserId: operator.id.toString(), + }); + if (!contractResult.ok) throw new Error('createContract failed'); + return contractResult.value.publicId; + } + + it('새로운 웹훅 이벤트를 처리하고 에스크로 상태가 변경된다', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업자', + primaryRole: 'FOUNDER', + }); + const contractPublicId = await createContractForWebhook(owner, operator, requester); + + const result = await processEscrowWebhookService(prisma, { + contractPublicId, + idempotencyKey: 'webhook-evt-001', + transactionType: 'DEPOSIT', + amount: 5000000, + providerCode: 'TOSS', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.isNew).toBe(true); + expect(result.value.escrowStatus).toBe('HOLDING'); + + // DB 확인 + const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } }); + expect(contract!.escrowStatus).toBe('HOLDING'); + + // EscrowTransaction 확인 + const transactions = await prisma.escrowTransaction.findMany({ + where: { contractId: contract!.id }, + }); + expect(transactions).toHaveLength(1); + expect(transactions[0]!.idempotencyKey).toBe('webhook-evt-001'); + expect(transactions[0]!.transactionType).toBe('DEPOSIT'); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: contractPublicId, actionType: 'ESCROW_TRANSACTION_PROCESSED' }, + }); + expect(auditLogs).toHaveLength(1); + }); + + it('동일 idempotencyKey로 중복 요청 시 isNew=false 반환 (멱등)', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업자', + primaryRole: 'FOUNDER', + }); + const contractPublicId = await createContractForWebhook(owner, operator, requester); + + // 1차 처리 + await processEscrowWebhookService(prisma, { + contractPublicId, + idempotencyKey: 'webhook-evt-dup', + transactionType: 'DEPOSIT', + amount: 5000000, + }); + + // 2차 처리 (중복) + const result = await processEscrowWebhookService(prisma, { + contractPublicId, + idempotencyKey: 'webhook-evt-dup', + transactionType: 'DEPOSIT', + amount: 5000000, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.isNew).toBe(false); + + // EscrowTransaction은 1건만 존재 + const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } }); + const transactions = await prisma.escrowTransaction.findMany({ + where: { contractId: contract!.id }, + }); + expect(transactions).toHaveLength(1); + }); + + it('빈 idempotencyKey는 실패', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업자', + primaryRole: 'FOUNDER', + }); + const contractPublicId = await createContractForWebhook(owner, operator, requester); + + const result = await processEscrowWebhookService(prisma, { + contractPublicId, + idempotencyKey: '', + transactionType: 'DEPOSIT', + amount: 5000000, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('VALIDATION_ERROR'); + }); + }); + + // --------------------------------------------------------------------------- + // I015: 분쟁 접수 + // --------------------------------------------------------------------------- + + describe('I015: openDisputeService', () => { + async function createActiveContractWithHolding( + owner: { id: bigint }, + operator: { id: bigint }, + requester: { id: bigint }, + ) { + const storePublicId = await createPublishedStore(owner.id, operator.id); + const matchPublicId = await createAcceptedMatchRequest( + storePublicId, + requester.id, + operator.id, + ); + const contractPolicy = await createContractPolicyVersion(); + + const contractResult = await createContractService(prisma, { + matchRequestPublicId: matchPublicId, + contractType: 'ACQUISITION', + templateCode: 'ACQ_STANDARD_V1', + policyVersionId: contractPolicy.id.toString(), + createdByUserId: operator.id.toString(), + }); + if (!contractResult.ok) throw new Error('createContract failed'); + + const contract = await prisma.contract.findUnique({ + where: { publicId: contractResult.value.publicId }, + }); + await prisma.contract.update({ + where: { id: contract!.id }, + data: { status: 'ACTIVE', escrowStatus: 'HOLDING' }, + }); + + return contractResult.value.publicId; + } + + it('ACTIVE 계약 + HOLDING 에스크로에서 분쟁 접수 성공', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업자', + primaryRole: 'FOUNDER', + }); + + const contractPublicId = await createActiveContractWithHolding(owner, operator, requester); + + const result = await openDisputeService( + prisma, + contractPublicId, + 'QUALITY_ISSUE', + owner.id.toString(), + '시공 품질이 계약 내용과 다릅니다.', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.escrowStatus).toBe('DISPUTED'); + + // DB 확인 + const contract = await prisma.contract.findUnique({ where: { publicId: contractPublicId } }); + expect(contract!.escrowStatus).toBe('DISPUTED'); + + // DisputeCase 확인 + const disputes = await prisma.disputeCase.findMany({ + where: { contractId: contract!.id }, + }); + expect(disputes).toHaveLength(1); + expect(disputes[0]!.reasonCode).toBe('QUALITY_ISSUE'); + expect(disputes[0]!.description).toBe('시공 품질이 계약 내용과 다릅니다.'); + expect(disputes[0]!.status).toBe('OPEN'); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: contractPublicId, actionType: 'DISPUTE_OPENED' }, + }); + expect(auditLogs).toHaveLength(1); + + // OutboxEvent 확인 + const outboxEvents = await prisma.outboxEvent.findMany({ + where: { aggregateId: contractPublicId, eventName: 'dispute.opened' }, + }); + expect(outboxEvents).toHaveLength(1); + }); + + it('DRAFT 계약에서는 분쟁 접수 불가', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업자', + primaryRole: 'FOUNDER', + }); + + const storePublicId = await createPublishedStore(owner.id, operator.id); + const matchPublicId = await createAcceptedMatchRequest( + storePublicId, + requester.id, + operator.id, + ); + const contractPolicy = await createContractPolicyVersion(); + + const contractResult = await createContractService(prisma, { + matchRequestPublicId: matchPublicId, + contractType: 'ACQUISITION', + templateCode: 'ACQ_STANDARD_V1', + policyVersionId: contractPolicy.id.toString(), + createdByUserId: operator.id.toString(), + }); + if (!contractResult.ok) throw new Error('createContract failed'); + + // DRAFT 상태 + NOT_STARTED 에스크로 (기본 상태 유지) + const result = await openDisputeService( + prisma, + contractResult.value.publicId, + 'QUALITY_ISSUE', + owner.id.toString(), + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('INVALID_CONTRACT_STATUS'); + }); + + it('존재하지 않는 계약에는 분쟁 접수 불가', async () => { + const owner = await createTestUser(); + + const result = await openDisputeService( + prisma, + 'non-existent-contract', + 'QUALITY_ISSUE', + owner.id.toString(), + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); + }); +}); diff --git a/apps/web/src/__tests__/integration/match-request-service.integration.test.ts b/apps/web/src/__tests__/integration/match-request-service.integration.test.ts new file mode 100644 index 0000000..f45e207 --- /dev/null +++ b/apps/web/src/__tests__/integration/match-request-service.integration.test.ts @@ -0,0 +1,419 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { + setupTestDatabase, + teardownTestDatabase, + cleanAllTables, + seedTestMasterData, +} from '@relink/database'; +import { + createStoreDraftService, + submitStoreService, + reviewStoreService, + publishStoreService, +} from '../../services/store-service'; +import { + createMatchRequestService, + acceptMatchRequestService, + searchStoresService, +} from '../../services/match-request-service'; + +describe('Match Request Service Integration Tests', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDatabase(); + }); + + afterAll(async () => { + await teardownTestDatabase(); + }); + + beforeEach(async () => { + await cleanAllTables(prisma); + await seedTestMasterData(prisma); + }); + + async function createTestUser(overrides: Record = {}) { + return prisma.user.create({ + data: { + email: 'owner@example.com', + emailNormalized: 'owner@example.com', + name: '매장 소유자', + primaryRole: 'CLOSING_OWNER', + status: 'ACTIVE', + ...overrides, + }, + }); + } + + async function createOperator(overrides: Record = {}) { + return prisma.user.create({ + data: { + email: 'ops@example.com', + emailNormalized: 'ops@example.com', + name: '운영자', + primaryRole: 'OPS_MANAGER', + status: 'ACTIVE', + ...overrides, + }, + }); + } + + async function createPublishedStore(ownerUserId: bigint, operatorUserId: bigint) { + const createResult = await createStoreDraftService(prisma, { + ownerUserId: ownerUserId.toString(), + listingTitle: '강남역 카페 양도', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 테헤란로 123', + }); + if (!createResult.ok) throw new Error('createStoreDraft failed'); + + await submitStoreService(prisma, createResult.value.publicId, ownerUserId.toString()); + await reviewStoreService(prisma, createResult.value.publicId, 'APPROVED', operatorUserId.toString()); + + const policyVersion = await prisma.policyVersion.create({ + data: { + policyType: 'STORE_LISTING_POLICY', + versionCode: 'v1.0', + contentHash: 'sha256-test-hash', + effectiveFrom: new Date(), + }, + }); + + await publishStoreService( + prisma, + createResult.value.publicId, + policyVersion.id.toString(), + operatorUserId.toString(), + ); + + return createResult.value.publicId; + } + + // --------------------------------------------------------------------------- + // I006: 매칭 요청 생성 통합 테스트 + // --------------------------------------------------------------------------- + + describe('I006: createMatchRequestService', () => { + it('공개 매장에 매칭 요청을 생성하고 AuditLog/OutboxEvent가 기록된다', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder@example.com', + emailNormalized: 'founder@example.com', + name: '창업 희망자', + primaryRole: 'FOUNDER', + }); + const storePublicId = await createPublishedStore(owner.id, operator.id); + + const result = await createMatchRequestService(prisma, { + storePublicId, + matchType: 'ACQUISITION', + sourceType: 'USER_REQUEST', + requesterUserId: requester.id.toString(), + message: '매장 인수 희망합니다.', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.status).toBe('OPEN'); + expect(result.value.matchType).toBe('ACQUISITION'); + expect(result.value.publicId).toBeTruthy(); + + // DB에 실제 저장 확인 + const matchRequest = await prisma.matchRequest.findUnique({ + where: { publicId: result.value.publicId }, + }); + expect(matchRequest).not.toBeNull(); + expect(matchRequest!.message).toBe('매장 인수 희망합니다.'); + expect(matchRequest!.requesterUserId).toBe(requester.id); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: result.value.publicId, actionType: 'MATCH_REQUEST_CREATED' }, + }); + expect(auditLogs).toHaveLength(1); + expect(auditLogs[0]!.actorUserId).toBe(requester.id); + + // OutboxEvent 확인 + const outboxEvents = await prisma.outboxEvent.findMany({ + where: { aggregateId: result.value.publicId }, + }); + expect(outboxEvents).toHaveLength(1); + expect(outboxEvents[0]!.eventName).toBe('match_request.created'); + }); + + it('비공개 매장에는 매칭 요청을 생성할 수 없다', async () => { + const owner = await createTestUser(); + const requester = await createTestUser({ + email: 'founder2@example.com', + emailNormalized: 'founder2@example.com', + name: '창업자2', + primaryRole: 'FOUNDER', + }); + + // DRAFT 상태 매장 (비공개) + const createResult = await createStoreDraftService(prisma, { + ownerUserId: owner.id.toString(), + listingTitle: '비공개 매장', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 123', + }); + if (!createResult.ok) throw new Error('createStoreDraft failed'); + + const result = await createMatchRequestService(prisma, { + storePublicId: createResult.value.publicId, + matchType: 'ACQUISITION', + sourceType: 'USER_REQUEST', + requesterUserId: requester.id.toString(), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('STORE_NOT_PUBLISHED'); + }); + + it('동일 사용자가 동일 매장에 열린 매칭 요청이 있으면 중복 생성 불가', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder3@example.com', + emailNormalized: 'founder3@example.com', + name: '창업자3', + primaryRole: 'FOUNDER', + }); + const storePublicId = await createPublishedStore(owner.id, operator.id); + + // 1차 매칭 요청 성공 + const first = await createMatchRequestService(prisma, { + storePublicId, + matchType: 'ACQUISITION', + sourceType: 'USER_REQUEST', + requesterUserId: requester.id.toString(), + }); + expect(first.ok).toBe(true); + + // 2차 매칭 요청 실패 (중복) + const second = await createMatchRequestService(prisma, { + storePublicId, + matchType: 'ACQUISITION', + sourceType: 'USER_REQUEST', + requesterUserId: requester.id.toString(), + }); + + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.error.code).toBe('DUPLICATE_OPEN_REQUEST'); + }); + + it('존재하지 않는 매장에는 매칭 요청 불가', async () => { + const requester = await createTestUser(); + + const result = await createMatchRequestService(prisma, { + storePublicId: 'non-existent-store', + matchType: 'ACQUISITION', + sourceType: 'USER_REQUEST', + requesterUserId: requester.id.toString(), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); + + it('운영자 추천 시 추천 사유 없으면 실패', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const storePublicId = await createPublishedStore(owner.id, operator.id); + + const result = await createMatchRequestService(prisma, { + storePublicId, + matchType: 'ACQUISITION', + sourceType: 'OPERATOR_RECOMMENDATION', + requesterUserId: operator.id.toString(), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('VALIDATION_ERROR'); + }); + }); + + // --------------------------------------------------------------------------- + // I007: 매칭 요청 수락 + 매장 검색 통합 테스트 + // --------------------------------------------------------------------------- + + describe('I007: acceptMatchRequestService', () => { + it('OPEN 상태의 매칭 요청을 ACCEPTED로 전환하고 감사 로그가 기록된다', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder4@example.com', + emailNormalized: 'founder4@example.com', + name: '창업자4', + primaryRole: 'FOUNDER', + }); + const storePublicId = await createPublishedStore(owner.id, operator.id); + + const createResult = await createMatchRequestService(prisma, { + storePublicId, + matchType: 'ACQUISITION', + sourceType: 'USER_REQUEST', + requesterUserId: requester.id.toString(), + message: '인수 희망', + }); + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + const acceptResult = await acceptMatchRequestService( + prisma, + createResult.value.publicId, + operator.id.toString(), + ); + + expect(acceptResult.ok).toBe(true); + if (!acceptResult.ok) return; + expect(acceptResult.value.status).toBe('ACCEPTED'); + + // DB 확인 + const matchRequest = await prisma.matchRequest.findUnique({ + where: { publicId: createResult.value.publicId }, + }); + expect(matchRequest!.status).toBe('ACCEPTED'); + expect(matchRequest!.acceptedAt).not.toBeNull(); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: createResult.value.publicId, actionType: 'MATCH_REQUEST_ACCEPTED' }, + }); + expect(auditLogs).toHaveLength(1); + expect((auditLogs[0]!.beforeJson as Record)?.status).toBe('OPEN'); + expect((auditLogs[0]!.afterJson as Record)?.status).toBe('ACCEPTED'); + + // OutboxEvent 확인 + const outboxEvents = await prisma.outboxEvent.findMany({ + where: { aggregateId: createResult.value.publicId, eventName: 'match_request.accepted' }, + }); + expect(outboxEvents).toHaveLength(1); + }); + + it('이미 ACCEPTED된 매칭 요청은 다시 수락 불가', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const requester = await createTestUser({ + email: 'founder5@example.com', + emailNormalized: 'founder5@example.com', + name: '창업자5', + primaryRole: 'FOUNDER', + }); + const storePublicId = await createPublishedStore(owner.id, operator.id); + + const createResult = await createMatchRequestService(prisma, { + storePublicId, + matchType: 'DEMOLITION', + sourceType: 'USER_REQUEST', + requesterUserId: requester.id.toString(), + }); + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + // 1차 수락 + await acceptMatchRequestService(prisma, createResult.value.publicId, operator.id.toString()); + + // 2차 수락 시도 + const secondAccept = await acceptMatchRequestService( + prisma, + createResult.value.publicId, + operator.id.toString(), + ); + + expect(secondAccept.ok).toBe(false); + if (secondAccept.ok) return; + expect(secondAccept.error.code).toBe('INVALID_STATUS_TRANSITION'); + }); + + it('존재하지 않는 매칭 요청은 NOT_FOUND를 반환한다', async () => { + const operator = await createOperator(); + + const result = await acceptMatchRequestService( + prisma, + 'non-existent-match', + operator.id.toString(), + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); + }); + + describe('I007: searchStoresService', () => { + it('공개 매장만 검색 결과에 포함된다', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + + // 공개 매장 1개 생성 + await createPublishedStore(owner.id, operator.id); + + // 비공개 매장 1개 생성 (DRAFT) + await createStoreDraftService(prisma, { + ownerUserId: owner.id.toString(), + listingTitle: '비공개 매장', + industryLeafCode: 'FNB.KOREAN', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 456', + }); + + const result = await searchStoresService(prisma, {}); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.stores).toHaveLength(1); + expect(result.value.stores[0]!.listingTitle).toBe('강남역 카페 양도'); + }); + + it('페이지네이션과 limit이 올바르게 적용된다', async () => { + const result = await searchStoresService(prisma, { page: 1, limit: 10 }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.page).toBe(1); + expect(result.value.limit).toBe(10); + }); + + it('limit이 100을 초과하면 100으로 제한된다', async () => { + const result = await searchStoresService(prisma, { limit: 500 }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.limit).toBe(100); + }); + + it('지역 코드로 필터링할 수 있다', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + await createPublishedStore(owner.id, operator.id); + + const result = await searchStoresService(prisma, { + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.stores).toHaveLength(1); + + // 존재하지 않는 지역 코드 + const emptyResult = await searchStoresService(prisma, { + regionClusterCode: 'KR.BUSAN', + }); + + expect(emptyResult.ok).toBe(true); + if (!emptyResult.ok) return; + expect(emptyResult.value.stores).toHaveLength(0); + }); + }); +}); diff --git a/apps/web/src/__tests__/integration/store-service.integration.test.ts b/apps/web/src/__tests__/integration/store-service.integration.test.ts new file mode 100644 index 0000000..8c5b27a --- /dev/null +++ b/apps/web/src/__tests__/integration/store-service.integration.test.ts @@ -0,0 +1,559 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { + setupTestDatabase, + teardownTestDatabase, + cleanAllTables, + seedTestMasterData, +} from '@relink/database'; +import { + createStoreDraftService, + submitStoreService, + reviewStoreService, + publishStoreService, + getStoreForViewer, +} from '../../services/store-service'; + +describe('Store Service Integration Tests', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDatabase(); + }); + + afterAll(async () => { + await teardownTestDatabase(); + }); + + beforeEach(async () => { + await cleanAllTables(prisma); + await seedTestMasterData(prisma); + }); + + async function createTestUser() { + return prisma.user.create({ + data: { + email: 'test@example.com', + emailNormalized: 'test@example.com', + name: '테스트 사용자', + primaryRole: 'CLOSING_OWNER', + status: 'ACTIVE', + }, + }); + } + + // --------------------------------------------------------------------------- + // I001: POST /api/v1/stores - 매장 초안 생성 + // --------------------------------------------------------------------------- + + describe('I001: createStoreDraftService', () => { + it('매장 초안을 생성하고 AuditLog/OutboxEvent가 함께 기록된다', async () => { + const user = await createTestUser(); + + const result = await createStoreDraftService(prisma, { + ownerUserId: user.id.toString(), + listingTitle: '강남역 카페 양도', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 테헤란로 123', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.reviewStatus).toBe('DRAFT'); + expect(result.value.publicationStatus).toBe('PRIVATE'); + expect(result.value.dealStatus).toBe('OPEN'); + expect(result.value.publicId).toBeTruthy(); + + // DB에 실제 저장되었는지 확인 + const store = await prisma.store.findUnique({ + where: { publicId: result.value.publicId }, + include: { industryLeaf: true, regionCluster: true }, + }); + expect(store).not.toBeNull(); + expect(store!.listingTitle).toBe('강남역 카페 양도'); + expect(store!.industryLeaf!.code).toBe('FNB.CAFE'); + expect(store!.regionCluster!.code).toBe('KR.BETA.GANGNAM_CORE'); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: result.value.publicId }, + }); + expect(auditLogs).toHaveLength(1); + expect(auditLogs[0]!.actionType).toBe('STORE_DRAFT_CREATED'); + expect(auditLogs[0]!.actorUserId).toBe(user.id); + + // OutboxEvent 확인 + const outboxEvents = await prisma.outboxEvent.findMany({ + where: { aggregateId: result.value.publicId }, + }); + expect(outboxEvents).toHaveLength(1); + expect(outboxEvents[0]!.eventName).toBe('store.draft.created'); + }); + + it('lease와 facility 정보를 포함한 매장을 생성한다', async () => { + const user = await createTestUser(); + + const result = await createStoreDraftService(prisma, { + ownerUserId: user.id.toString(), + listingTitle: '강남 한식당', + industryLeafCode: 'FNB.KOREAN', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 역삼동 123', + lease: { + depositAmount: 50_000_000, + monthlyRentAmount: 3_000_000, + premiumAmount: 100_000_000, + }, + facility: { + exclusiveAreaSqm: 66.12, + seatCount: 30, + floorLevel: 1, + hasGas: true, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const store = await prisma.store.findUnique({ + where: { publicId: result.value.publicId }, + include: { lease: true, facility: true }, + }); + + expect(store!.lease).not.toBeNull(); + expect(Number(store!.lease!.depositAmount)).toBe(50_000_000); + expect(Number(store!.lease!.monthlyRentAmount)).toBe(3_000_000); + expect(Number(store!.lease!.premiumAmount)).toBe(100_000_000); + + expect(store!.facility).not.toBeNull(); + expect(Number(store!.facility!.exclusiveAreaSqm)).toBeCloseTo(66.12); + expect(store!.facility!.seatCount).toBe(30); + expect(store!.facility!.floorLevel).toBe(1); + expect(store!.facility!.hasGas).toBe(true); + }); + + it('베타 미지원 지역은 거부한다', async () => { + const user = await createTestUser(); + + const result = await createStoreDraftService(prisma, { + ownerUserId: user.id.toString(), + listingTitle: '부산 카페', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BUSAN', + roadAddress: '부산시 해운대구 123', + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('REGION_NOT_BETA_ENABLED'); + }); + + it('미지원 업종 코드는 거부한다', async () => { + const user = await createTestUser(); + + const result = await createStoreDraftService(prisma, { + ownerUserId: user.id.toString(), + listingTitle: '강남 뷰티샵', + industryLeafCode: 'BEAUTY.NAIL', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 123', + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('INDUSTRY_NOT_SUPPORTED'); + }); + }); + + // --------------------------------------------------------------------------- + // I002: POST /api/v1/stores/:id/submit - 매장 제출 + // --------------------------------------------------------------------------- + + describe('I002: submitStoreService', () => { + it('DRAFT 상태의 매장을 SUBMITTED로 전환한다', async () => { + const user = await createTestUser(); + + const createResult = await createStoreDraftService(prisma, { + ownerUserId: user.id.toString(), + listingTitle: '강남역 카페', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 테헤란로 123', + }); + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + const submitResult = await submitStoreService( + prisma, + createResult.value.publicId, + user.id.toString(), + ); + + expect(submitResult.ok).toBe(true); + if (!submitResult.ok) return; + expect(submitResult.value.reviewStatus).toBe('SUBMITTED'); + + // AuditLog에 상태 전환 기록 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { + resourceId: createResult.value.publicId, + actionType: 'STORE_SUBMITTED', + }, + }); + expect(auditLogs).toHaveLength(1); + expect((auditLogs[0]!.beforeJson as Record)?.reviewStatus).toBe('DRAFT'); + expect((auditLogs[0]!.afterJson as Record)?.reviewStatus).toBe('SUBMITTED'); + }); + + it('DRAFT가 아닌 매장은 제출을 거부한다', async () => { + const user = await createTestUser(); + + const createResult = await createStoreDraftService(prisma, { + ownerUserId: user.id.toString(), + listingTitle: '강남역 카페', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 테헤란로 123', + }); + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + // 1차 제출 + await submitStoreService(prisma, createResult.value.publicId, user.id.toString()); + + // 2차 제출 시도 + const secondSubmit = await submitStoreService( + prisma, + createResult.value.publicId, + user.id.toString(), + ); + + expect(secondSubmit.ok).toBe(false); + if (secondSubmit.ok) return; + expect(secondSubmit.error.code).toBe('INVALID_STATUS_TRANSITION'); + }); + + it('존재하지 않는 매장은 NOT_FOUND를 반환한다', async () => { + const result = await submitStoreService(prisma, 'non-existent-id', '1'); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); + }); + + // --------------------------------------------------------------------------- + // I003: GET /api/v1/master-data - 마스터 데이터 조회 + // --------------------------------------------------------------------------- + + describe('I003: master-data 쿼리', () => { + it('베타 활성 지역과 리프 업종만 반환한다', async () => { + const [regions, industries] = await Promise.all([ + prisma.regionHierarchy.findMany({ + where: { isActive: true, isBetaEnabled: true }, + select: { + code: true, + nameKo: true, + regionType: true, + parentId: true, + depth: true, + isBetaEnabled: true, + }, + orderBy: [{ depth: 'asc' }, { sortOrder: 'asc' }], + }), + prisma.industryTaxonomy.findMany({ + where: { isActive: true, isBetaEnabled: true, isLeaf: true }, + select: { + code: true, + nameKo: true, + depth: true, + isLeaf: true, + isBetaEnabled: true, + }, + orderBy: { sortOrder: 'asc' }, + }), + ]); + + // seedTestMasterData 기준: 베타 클러스터 1개 + expect(regions.length).toBeGreaterThanOrEqual(1); + const betaCluster = regions.find((r) => r.code === 'KR.BETA.GANGNAM_CORE'); + expect(betaCluster).toBeDefined(); + expect(betaCluster!.nameKo).toBe('강남권 베타 클러스터'); + + // seedTestMasterData 기준: 리프 업종 2개 (카페, 한식) + expect(industries.length).toBeGreaterThanOrEqual(2); + expect(industries.find((i) => i.code === 'FNB.CAFE')).toBeDefined(); + expect(industries.find((i) => i.code === 'FNB.KOREAN')).toBeDefined(); + + // 부모 업종(FNB)은 isLeaf=false이므로 제외 + expect(industries.find((i) => i.code === 'FNB')).toBeUndefined(); + + // 비베타 지역(KR.BUSAN)은 제외 + expect(regions.find((r) => r.code === 'KR.BUSAN')).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // I004: 운영자 검토 API - 승인/반려 + 감사 로그 + // --------------------------------------------------------------------------- + + describe('I004: reviewStoreService / publishStoreService', () => { + async function createAndSubmitStore(userId: bigint) { + const createResult = await createStoreDraftService(prisma, { + ownerUserId: userId.toString(), + listingTitle: '강남역 카페', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 테헤란로 123', + detailAddress: '4층 401호', + }); + if (!createResult.ok) throw new Error('createStoreDraft failed'); + + await submitStoreService(prisma, createResult.value.publicId, userId.toString()); + return createResult.value.publicId; + } + + it('운영자가 매장을 승인하면 APPROVED로 전환되고 감사 로그가 기록된다', async () => { + const user = await createTestUser(); + const operator = await prisma.user.create({ + data: { + email: 'ops@example.com', + emailNormalized: 'ops@example.com', + name: '운영자', + primaryRole: 'OPS_MANAGER', + status: 'ACTIVE', + }, + }); + + const storePublicId = await createAndSubmitStore(user.id); + + const result = await reviewStoreService( + prisma, + storePublicId, + 'APPROVED', + operator.id.toString(), + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.reviewStatus).toBe('APPROVED'); + + // 감사 로그 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: storePublicId, actionType: 'STORE_APPROVED' }, + }); + expect(auditLogs).toHaveLength(1); + expect(auditLogs[0]!.actorUserId).toBe(operator.id); + expect((auditLogs[0]!.beforeJson as Record)?.reviewStatus).toBe('SUBMITTED'); + expect((auditLogs[0]!.afterJson as Record)?.reviewStatus).toBe('APPROVED'); + }); + + it('운영자가 매장을 반려하면 REJECTED로 전환되고 사유가 기록된다', async () => { + const user = await createTestUser(); + const operator = await prisma.user.create({ + data: { + email: 'ops2@example.com', + emailNormalized: 'ops2@example.com', + name: '운영자2', + primaryRole: 'OPS_MANAGER', + status: 'ACTIVE', + }, + }); + + const storePublicId = await createAndSubmitStore(user.id); + + const result = await reviewStoreService( + prisma, + storePublicId, + 'REJECTED', + operator.id.toString(), + 'INCOMPLETE_INFO', + '시설 정보가 부족합니다.', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.reviewStatus).toBe('REJECTED'); + + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: storePublicId, actionType: 'STORE_REJECTED' }, + }); + expect(auditLogs).toHaveLength(1); + const afterJson = auditLogs[0]!.afterJson as Record; + expect(afterJson.reasonCode).toBe('INCOMPLETE_INFO'); + expect(afterJson.memo).toBe('시설 정보가 부족합니다.'); + }); + + it('승인된 매장을 정책 버전과 함께 공개하면 PUBLISHED로 전환된다', async () => { + const user = await createTestUser(); + const operator = await prisma.user.create({ + data: { + email: 'ops3@example.com', + emailNormalized: 'ops3@example.com', + name: '운영자3', + primaryRole: 'OPS_MANAGER', + status: 'ACTIVE', + }, + }); + + const storePublicId = await createAndSubmitStore(user.id); + await reviewStoreService(prisma, storePublicId, 'APPROVED', operator.id.toString()); + + // 정책 버전 생성 + const policyVersion = await prisma.policyVersion.create({ + data: { + policyType: 'STORE_LISTING_POLICY', + versionCode: 'v1.0', + contentHash: 'sha256-test-hash', + effectiveFrom: new Date(), + }, + }); + + const publishResult = await publishStoreService( + prisma, + storePublicId, + policyVersion.id.toString(), + operator.id.toString(), + ); + + expect(publishResult.ok).toBe(true); + if (!publishResult.ok) return; + expect(publishResult.value.publicationStatus).toBe('PUBLISHED'); + + // DB에서 정책 버전 연결 확인 + const store = await prisma.store.findUnique({ where: { publicId: storePublicId } }); + expect(store!.policyVersionId).toBe(policyVersion.id); + expect(store!.publishedAt).not.toBeNull(); + + // 감사 로그 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: storePublicId, actionType: 'STORE_PUBLISHED' }, + }); + expect(auditLogs).toHaveLength(1); + }); + }); + + // --------------------------------------------------------------------------- + // I005: 공개 매장 조회 API - 제한 공개 정책 적용 + // --------------------------------------------------------------------------- + + describe('I005: getStoreForViewer - 제한 공개 정책', () => { + async function createPublishedStore() { + const owner = await createTestUser(); + const operator = await prisma.user.create({ + data: { + email: 'ops5@example.com', + emailNormalized: 'ops5@example.com', + name: '운영자5', + primaryRole: 'OPS_MANAGER', + status: 'ACTIVE', + phone: '010-9999-8888', + }, + }); + + const createResult = await createStoreDraftService(prisma, { + ownerUserId: owner.id.toString(), + listingTitle: '강남역 카페 양도', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 테헤란로 123', + detailAddress: '4층 401호', + }); + if (!createResult.ok) throw new Error('create failed'); + + await submitStoreService(prisma, createResult.value.publicId, owner.id.toString()); + await reviewStoreService( + prisma, + createResult.value.publicId, + 'APPROVED', + operator.id.toString(), + ); + + const policyVersion = await prisma.policyVersion.create({ + data: { + policyType: 'STORE_LISTING_POLICY', + versionCode: 'v1.0', + contentHash: 'sha256-test-hash-2', + effectiveFrom: new Date(), + }, + }); + + await publishStoreService( + prisma, + createResult.value.publicId, + policyVersion.id.toString(), + operator.id.toString(), + ); + + return { owner, operator, storePublicId: createResult.value.publicId }; + } + + it('소유자는 공개 매장의 상세 주소와 연락처를 볼 수 있다', async () => { + const { owner, storePublicId } = await createPublishedStore(); + + const result = await getStoreForViewer(prisma, storePublicId, { + userId: owner.publicId, + role: 'CLOSING_OWNER', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.detailAddress).toBe('4층 401호'); + expect(result.value.ownerEmail).toBe('test@example.com'); + }); + + it('운영자는 공개 매장의 상세 주소와 연락처를 볼 수 있다', async () => { + const { operator, storePublicId } = await createPublishedStore(); + + const result = await getStoreForViewer(prisma, storePublicId, { + userId: operator.publicId, + role: 'OPS_MANAGER', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.detailAddress).toBe('4층 401호'); + }); + + it('일반 창업자는 공개 매장의 상세 주소와 연락처를 볼 수 없다', async () => { + const { storePublicId } = await createPublishedStore(); + + const result = await getStoreForViewer(prisma, storePublicId, { + userId: 'founder-random', + role: 'FOUNDER', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.detailAddress).toBeUndefined(); + expect(result.value.ownerPhone).toBeUndefined(); + expect(result.value.ownerEmail).toBeUndefined(); + // 공개 정보는 그대로 + expect(result.value.listingTitle).toBe('강남역 카페 양도'); + expect(result.value.roadAddress).toBe('서울시 강남구 테헤란로 123'); + }); + + it('비공개 매장은 일반 사용자에게 FORBIDDEN을 반환한다', async () => { + const owner = await createTestUser(); + const createResult = await createStoreDraftService(prisma, { + ownerUserId: owner.id.toString(), + listingTitle: '비공개 매장', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 123', + }); + if (!createResult.ok) throw new Error('create failed'); + + const result = await getStoreForViewer(prisma, createResult.value.publicId, { + userId: 'founder-random', + role: 'FOUNDER', + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('FORBIDDEN'); + }); + }); +}); diff --git a/apps/web/src/__tests__/integration/subsidy-case-service.integration.test.ts b/apps/web/src/__tests__/integration/subsidy-case-service.integration.test.ts new file mode 100644 index 0000000..ea87a64 --- /dev/null +++ b/apps/web/src/__tests__/integration/subsidy-case-service.integration.test.ts @@ -0,0 +1,403 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { + setupTestDatabase, + teardownTestDatabase, + cleanAllTables, + seedTestMasterData, +} from '@relink/database'; +import { + createStoreDraftService, + submitStoreService, + reviewStoreService, + publishStoreService, +} from '../../services/store-service'; +import { + createSubsidyCaseService, + reviewSubsidyCaseService, +} from '../../services/subsidy-case-service'; + +describe('Subsidy Case Service Integration Tests', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDatabase(); + }); + + afterAll(async () => { + await teardownTestDatabase(); + }); + + beforeEach(async () => { + await cleanAllTables(prisma); + await seedTestMasterData(prisma); + }); + + async function createTestUser(overrides: Record = {}) { + return prisma.user.create({ + data: { + email: 'owner@example.com', + emailNormalized: 'owner@example.com', + name: '매장 소유자', + primaryRole: 'CLOSING_OWNER', + status: 'ACTIVE', + ...overrides, + }, + }); + } + + async function createOperator(overrides: Record = {}) { + return prisma.user.create({ + data: { + email: 'ops@example.com', + emailNormalized: 'ops@example.com', + name: '운영자', + primaryRole: 'OPS_MANAGER', + status: 'ACTIVE', + ...overrides, + }, + }); + } + + async function createSubsidyPolicy() { + return prisma.policyVersion.create({ + data: { + policyType: 'SUBSIDY_CHECKLIST', + versionCode: 'v1.0', + contentHash: 'sha256-subsidy-policy-hash', + effectiveFrom: new Date(), + isActive: true, + }, + }); + } + + async function createPublishedStore(ownerUserId: bigint, operatorUserId: bigint) { + const createResult = await createStoreDraftService(prisma, { + ownerUserId: ownerUserId.toString(), + listingTitle: '강남역 카페 양도', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 테헤란로 123', + }); + if (!createResult.ok) throw new Error('createStoreDraft failed'); + + await submitStoreService(prisma, createResult.value.publicId, ownerUserId.toString()); + await reviewStoreService( + prisma, + createResult.value.publicId, + 'APPROVED', + operatorUserId.toString(), + ); + + const storePolicyVersion = await prisma.policyVersion.create({ + data: { + policyType: 'STORE_LISTING_POLICY', + versionCode: 'v1.0', + contentHash: 'sha256-store-hash', + effectiveFrom: new Date(), + }, + }); + + await publishStoreService( + prisma, + createResult.value.publicId, + storePolicyVersion.id.toString(), + operatorUserId.toString(), + ); + + return createResult.value.publicId; + } + + // --------------------------------------------------------------------------- + // I008: POST /api/v1/subsidies/cases - 케이스와 초기 체크리스트 생성 + // --------------------------------------------------------------------------- + + describe('I008: createSubsidyCaseService', () => { + it('공개 매장에 지원금 케이스를 생성하고 체크리스트/AuditLog/OutboxEvent가 기록된다', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const storePublicId = await createPublishedStore(owner.id, operator.id); + await createSubsidyPolicy(); + + const result = await createSubsidyCaseService(prisma, { + storePublicId, + applicantUserId: owner.id.toString(), + programCode: 'SMALL_BIZ_CLOSURE_2024', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.status).toBe('DRAFT'); + expect(result.value.programCode).toBe('SMALL_BIZ_CLOSURE_2024'); + expect(result.value.publicId).toBeTruthy(); + + // DB에 실제 저장 확인 + const subsidyCase = await prisma.subsidyCase.findUnique({ + where: { publicId: result.value.publicId }, + include: { checklistItems: true }, + }); + expect(subsidyCase).not.toBeNull(); + expect(subsidyCase!.checklistVersionCode).toBe('v1.0'); + expect(subsidyCase!.policyVersionId).not.toBeNull(); + + // 체크리스트 항목 확인 + expect(subsidyCase!.checklistItems.length).toBeGreaterThanOrEqual(2); + const requiredItems = subsidyCase!.checklistItems.filter( + (item: { isRequired: boolean }) => item.isRequired, + ); + expect(requiredItems.length).toBeGreaterThanOrEqual(2); + expect( + subsidyCase!.checklistItems.every((item: { status: string }) => item.status === 'PENDING'), + ).toBe(true); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: result.value.publicId, actionType: 'SUBSIDY_CASE_CREATED' }, + }); + expect(auditLogs).toHaveLength(1); + expect(auditLogs[0]!.actorUserId).toBe(owner.id); + + // OutboxEvent 확인 + const outboxEvents = await prisma.outboxEvent.findMany({ + where: { aggregateId: result.value.publicId }, + }); + expect(outboxEvents).toHaveLength(1); + expect(outboxEvents[0]!.eventName).toBe('subsidy_case.created'); + }); + + it('DRAFT 상태(비공개+미검토) 매장에서는 케이스 생성 불가', async () => { + const owner = await createTestUser(); + await createSubsidyPolicy(); + + // DRAFT 매장 생성 (비공개) + const createResult = await createStoreDraftService(prisma, { + ownerUserId: owner.id.toString(), + listingTitle: '비공개 매장', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 123', + }); + if (!createResult.ok) throw new Error('createStoreDraft failed'); + + const result = await createSubsidyCaseService(prisma, { + storePublicId: createResult.value.publicId, + applicantUserId: owner.id.toString(), + programCode: 'SMALL_BIZ_CLOSURE_2024', + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('STORE_NOT_ELIGIBLE'); + }); + + it('SUBMITTED(검토 중) 매장은 케이스 생성 가능', async () => { + const owner = await createTestUser(); + await createSubsidyPolicy(); + + const createResult = await createStoreDraftService(prisma, { + ownerUserId: owner.id.toString(), + listingTitle: '제출된 매장', + industryLeafCode: 'FNB.CAFE', + regionClusterCode: 'KR.BETA.GANGNAM_CORE', + roadAddress: '서울시 강남구 456', + }); + if (!createResult.ok) throw new Error('createStoreDraft failed'); + + await submitStoreService(prisma, createResult.value.publicId, owner.id.toString()); + + const result = await createSubsidyCaseService(prisma, { + storePublicId: createResult.value.publicId, + applicantUserId: owner.id.toString(), + programCode: 'SMALL_BIZ_CLOSURE_2024', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.status).toBe('DRAFT'); + }); + + it('존재하지 않는 매장에는 케이스 생성 불가', async () => { + const owner = await createTestUser(); + await createSubsidyPolicy(); + + const result = await createSubsidyCaseService(prisma, { + storePublicId: 'non-existent-store', + applicantUserId: owner.id.toString(), + programCode: 'SMALL_BIZ_CLOSURE_2024', + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); + + it('활성 정책이 없으면 케이스 생성 불가', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const storePublicId = await createPublishedStore(owner.id, operator.id); + // 정책 미생성 + + const result = await createSubsidyCaseService(prisma, { + storePublicId, + applicantUserId: owner.id.toString(), + programCode: 'SMALL_BIZ_CLOSURE_2024', + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); + }); + + // --------------------------------------------------------------------------- + // I009: PATCH /api/v1/subsidies/cases/:id/review - 상태 변경과 감사 로그 + // --------------------------------------------------------------------------- + + describe('I009: reviewSubsidyCaseService', () => { + async function createSubmittedCase() { + const owner = await createTestUser(); + const operator = await createOperator(); + const storePublicId = await createPublishedStore(owner.id, operator.id); + await createSubsidyPolicy(); + + const createResult = await createSubsidyCaseService(prisma, { + storePublicId, + applicantUserId: owner.id.toString(), + programCode: 'SMALL_BIZ_CLOSURE_2024', + }); + if (!createResult.ok) throw new Error('createSubsidyCase failed'); + + // 상태를 SUBMITTED로 직접 변경 (검토 가능 상태로 만들기) + await prisma.subsidyCase.update({ + where: { publicId: createResult.value.publicId }, + data: { status: 'SUBMITTED', submittedAt: new Date() }, + }); + + return { owner, operator, subsidyCasePublicId: createResult.value.publicId }; + } + + it('SUBMITTED 케이스를 승인하면 APPROVED로 전환되고 감사 로그가 기록된다', async () => { + const { operator, subsidyCasePublicId } = await createSubmittedCase(); + + const result = await reviewSubsidyCaseService( + prisma, + subsidyCasePublicId, + 'APPROVED', + operator.id.toString(), + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.status).toBe('APPROVED'); + + // DB 확인 + const subsidyCase = await prisma.subsidyCase.findUnique({ + where: { publicId: subsidyCasePublicId }, + }); + expect(subsidyCase!.status).toBe('APPROVED'); + expect(subsidyCase!.reviewedAt).not.toBeNull(); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: subsidyCasePublicId, actionType: 'SUBSIDY_CASE_APPROVED' }, + }); + expect(auditLogs).toHaveLength(1); + expect((auditLogs[0]!.beforeJson as Record)?.status).toBe('SUBMITTED'); + expect((auditLogs[0]!.afterJson as Record)?.status).toBe('APPROVED'); + + // OutboxEvent 확인 + const outboxEvents = await prisma.outboxEvent.findMany({ + where: { aggregateId: subsidyCasePublicId, eventName: 'subsidy_case.approved' }, + }); + expect(outboxEvents).toHaveLength(1); + }); + + it('SUBMITTED 케이스를 반려하면 REJECTED로 전환되고 사유가 기록된다', async () => { + const { operator, subsidyCasePublicId } = await createSubmittedCase(); + + const result = await reviewSubsidyCaseService( + prisma, + subsidyCasePublicId, + 'REJECTED', + operator.id.toString(), + 'INCOMPLETE_DOCUMENTS', + '사업자등록증 만료. 갱신본 제출 필요.', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.status).toBe('REJECTED'); + + // DB 확인 + const subsidyCase = await prisma.subsidyCase.findUnique({ + where: { publicId: subsidyCasePublicId }, + }); + expect(subsidyCase!.rejectionReasonCode).toBe('INCOMPLETE_DOCUMENTS'); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: subsidyCasePublicId, actionType: 'SUBSIDY_CASE_REJECTED' }, + }); + expect(auditLogs).toHaveLength(1); + const afterJson = auditLogs[0]!.afterJson as Record; + expect(afterJson.rejectionReasonCode).toBe('INCOMPLETE_DOCUMENTS'); + expect(afterJson.memo).toBe('사업자등록증 만료. 갱신본 제출 필요.'); + }); + + it('반려 시 사유 코드 없으면 실패', async () => { + const { operator, subsidyCasePublicId } = await createSubmittedCase(); + + const result = await reviewSubsidyCaseService( + prisma, + subsidyCasePublicId, + 'REJECTED', + operator.id.toString(), + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('REJECTION_REASON_REQUIRED'); + }); + + it('DRAFT 상태에서는 검토 불가', async () => { + const owner = await createTestUser(); + const operator = await createOperator(); + const storePublicId = await createPublishedStore(owner.id, operator.id); + await createSubsidyPolicy(); + + const createResult = await createSubsidyCaseService(prisma, { + storePublicId, + applicantUserId: owner.id.toString(), + programCode: 'SMALL_BIZ_CLOSURE_2024', + }); + if (!createResult.ok) throw new Error('createSubsidyCase failed'); + + const result = await reviewSubsidyCaseService( + prisma, + createResult.value.publicId, + 'APPROVED', + operator.id.toString(), + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('INVALID_STATUS_TRANSITION'); + }); + + it('존재하지 않는 케이스는 NOT_FOUND를 반환한다', async () => { + const operator = await createOperator(); + + const result = await reviewSubsidyCaseService( + prisma, + 'non-existent-case', + 'APPROVED', + operator.id.toString(), + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); + }); +}); diff --git a/apps/web/src/__tests__/integration/vendor-certification-service.integration.test.ts b/apps/web/src/__tests__/integration/vendor-certification-service.integration.test.ts new file mode 100644 index 0000000..b9e0a41 --- /dev/null +++ b/apps/web/src/__tests__/integration/vendor-certification-service.integration.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { + setupTestDatabase, + teardownTestDatabase, + cleanAllTables, + seedTestMasterData, +} from '@relink/database'; +import { + applyVendorCertificationService, + reviewVendorCertificationService, +} from '../../services/vendor-certification-service'; + +describe('Vendor Certification Service Integration Tests', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDatabase(); + }); + + afterAll(async () => { + await teardownTestDatabase(); + }); + + beforeEach(async () => { + await cleanAllTables(prisma); + await seedTestMasterData(prisma); + }); + + async function createVendorOwner(overrides: Record = {}) { + return prisma.user.create({ + data: { + email: 'vendor-owner@example.com', + emailNormalized: 'vendor-owner@example.com', + name: '업체 대표', + primaryRole: 'VENDOR_MANAGER', + status: 'ACTIVE', + ...overrides, + }, + }); + } + + async function createOperator(overrides: Record = {}) { + return prisma.user.create({ + data: { + email: 'ops@example.com', + emailNormalized: 'ops@example.com', + name: '운영자', + primaryRole: 'OPS_MANAGER', + status: 'ACTIVE', + ...overrides, + }, + }); + } + + // --------------------------------------------------------------------------- + // I010: POST /api/v1/vendors/certifications - 인증 신청과 검토 큐 생성 + // --------------------------------------------------------------------------- + + describe('I010: applyVendorCertificationService', () => { + it('업체 인증 신청 성공 시 APPLIED 상태로 생성되고 AuditLog/OutboxEvent 기록', async () => { + const owner = await createVendorOwner(); + + const result = await applyVendorCertificationService(prisma, { + ownerUserId: owner.id.toString(), + vendorType: 'DEMOLITION', + businessName: '(주)클린철거', + contactName: '김담당', + businessRegistrationNumber: '123-45-67890', + coverageRegionCodes: ['KR.BETA.GANGNAM_CORE'], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value.certificationStatus).toBe('APPLIED'); + expect(result.value.vendorPublicId).toBeTruthy(); + + // DB에 실제 저장 확인 + const vendor = await prisma.vendor.findUnique({ + where: { publicId: result.value.vendorPublicId }, + include: { coverageRegions: true, certifications: true }, + }); + expect(vendor).not.toBeNull(); + expect(vendor!.businessName).toBe('(주)클린철거'); + expect(vendor!.vendorType).toBe('DEMOLITION'); + + // 서비스 권역 확인 + expect(vendor!.coverageRegions).toHaveLength(1); + expect(vendor!.coverageRegions[0]!.isPrimary).toBe(true); + + // 인증 이력 확인 + expect(vendor!.certifications).toHaveLength(1); + expect(vendor!.certifications[0]!.status).toBe('APPLIED'); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { + resourceId: result.value.vendorPublicId, + actionType: 'VENDOR_CERTIFICATION_APPLIED', + }, + }); + expect(auditLogs).toHaveLength(1); + + // OutboxEvent 확인 + const outboxEvents = await prisma.outboxEvent.findMany({ + where: { aggregateId: result.value.vendorPublicId }, + }); + expect(outboxEvents).toHaveLength(1); + expect(outboxEvents[0]!.eventName).toBe('vendor.certification.applied'); + }); + + it('사업자등록증 없으면 인증 신청 실패', async () => { + const owner = await createVendorOwner(); + + const result = await applyVendorCertificationService(prisma, { + ownerUserId: owner.id.toString(), + vendorType: 'INTERIOR', + businessName: '인테리어 스튜디오', + contactName: '이담당', + coverageRegionCodes: ['KR.BETA.GANGNAM_CORE'], + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('MISSING_BUSINESS_REGISTRATION'); + }); + + it('서비스 권역이 유효하지 않으면 인증 신청 실패', async () => { + const owner = await createVendorOwner(); + + const result = await applyVendorCertificationService(prisma, { + ownerUserId: owner.id.toString(), + vendorType: 'DEMOLITION', + businessName: '(주)클린철거', + contactName: '김담당', + businessRegistrationNumber: '123-45-67890', + coverageRegionCodes: ['INVALID_REGION'], + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('MISSING_COVERAGE_REGION'); + }); + }); + + // --------------------------------------------------------------------------- + // I011: PATCH /api/v1/vendors/certifications/:id/approve - 인증 승인 + // --------------------------------------------------------------------------- + + describe('I011: reviewVendorCertificationService', () => { + async function createAppliedVendor() { + const owner = await createVendorOwner(); + const operator = await createOperator(); + + const applyResult = await applyVendorCertificationService(prisma, { + ownerUserId: owner.id.toString(), + vendorType: 'DEMOLITION', + businessName: '(주)클린철거', + contactName: '김담당', + businessRegistrationNumber: '123-45-67890', + coverageRegionCodes: ['KR.BETA.GANGNAM_CORE'], + }); + if (!applyResult.ok) throw new Error('apply failed'); + + // 서류 체크리스트 추가 (승인 필수 조건) + const vendor = await prisma.vendor.findUnique({ + where: { publicId: applyResult.value.vendorPublicId }, + }); + const cert = await prisma.vendorCertification.findFirst({ + where: { vendorId: vendor!.id }, + }); + if (cert) { + await prisma.vendorCertification.update({ + where: { id: cert.id }, + data: { documentChecklistJson: { businessLicense: true, insurance: true } }, + }); + } + + return { owner, operator, vendorPublicId: applyResult.value.vendorPublicId }; + } + + it('APPLIED 업체를 승인하면 APPROVED로 전환되고 이력+감사 로그 기록', async () => { + const { operator, vendorPublicId } = await createAppliedVendor(); + + const result = await reviewVendorCertificationService( + prisma, + vendorPublicId, + 'APPROVED', + operator.id.toString(), + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.certificationStatus).toBe('APPROVED'); + + // DB 확인 + const vendor = await prisma.vendor.findUnique({ + where: { publicId: vendorPublicId }, + include: { certifications: { orderBy: { createdAt: 'desc' } } }, + }); + expect(vendor!.certificationStatus).toBe('APPROVED'); + + // 인증 이력이 2개 (APPLIED + APPROVED) + expect(vendor!.certifications).toHaveLength(2); + expect(vendor!.certifications[0]!.status).toBe('APPROVED'); + expect(vendor!.certifications[0]!.reviewedAt).not.toBeNull(); + + // AuditLog 확인 + const auditLogs = await prisma.auditLog.findMany({ + where: { resourceId: vendorPublicId, actionType: 'VENDOR_CERTIFICATION_APPROVED' }, + }); + expect(auditLogs).toHaveLength(1); + expect((auditLogs[0]!.beforeJson as Record)?.certificationStatus).toBe( + 'APPLIED', + ); + expect((auditLogs[0]!.afterJson as Record)?.certificationStatus).toBe( + 'APPROVED', + ); + + // OutboxEvent 확인 + const outboxEvents = await prisma.outboxEvent.findMany({ + where: { aggregateId: vendorPublicId, eventName: 'vendor.certification.approved' }, + }); + expect(outboxEvents).toHaveLength(1); + }); + + it('반려 시 사유 코드가 있으면 REJECTED로 전환', async () => { + const { operator, vendorPublicId } = await createAppliedVendor(); + + const result = await reviewVendorCertificationService( + prisma, + vendorPublicId, + 'REJECTED', + operator.id.toString(), + 'INCOMPLETE_DOCUMENTS', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.certificationStatus).toBe('REJECTED'); + + // 인증 이력에 사유 코드 기록 + const vendor = await prisma.vendor.findUnique({ + where: { publicId: vendorPublicId }, + include: { certifications: { orderBy: { createdAt: 'desc' } } }, + }); + expect(vendor!.certifications[0]!.reasonCode).toBe('INCOMPLETE_DOCUMENTS'); + }); + + it('반려 시 사유 코드 없으면 실패', async () => { + const { operator, vendorPublicId } = await createAppliedVendor(); + + const result = await reviewVendorCertificationService( + prisma, + vendorPublicId, + 'REJECTED', + operator.id.toString(), + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('REASON_REQUIRED'); + }); + + it('존재하지 않는 업체는 NOT_FOUND를 반환', async () => { + const operator = await createOperator(); + + const result = await reviewVendorCertificationService( + prisma, + 'non-existent-vendor', + 'APPROVED', + operator.id.toString(), + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); + + it('이미 APPROVED된 업체는 재검토 불가', async () => { + const { operator, vendorPublicId } = await createAppliedVendor(); + + // 1차 승인 + await reviewVendorCertificationService( + prisma, + vendorPublicId, + 'APPROVED', + operator.id.toString(), + ); + + // 2차 승인 시도 + const result = await reviewVendorCertificationService( + prisma, + vendorPublicId, + 'APPROVED', + operator.id.toString(), + ); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('INVALID_STATUS_TRANSITION'); + }); + }); +}); diff --git a/apps/web/src/app/admin/contracts/page.tsx b/apps/web/src/app/admin/contracts/page.tsx new file mode 100644 index 0000000..7cdf03e --- /dev/null +++ b/apps/web/src/app/admin/contracts/page.tsx @@ -0,0 +1,103 @@ +const SAMPLE_CONTRACTS = [ + { id: 'ac-1', storeTitle: '선릉역 한식당', type: '철거', status: 'ACTIVE', escrow: 'RELEASE_REVIEW', amount: '₩5,000,000', createdAt: '2026-03-02' }, + { id: 'ac-2', storeTitle: '강남역 카페', type: '시설인수', status: 'SIGNED', escrow: 'HOLDING', amount: '₩50,000,000', createdAt: '2026-03-05' }, + { id: 'ac-3', storeTitle: '홍대 디저트카페', type: '인테리어', status: 'ACTIVE', escrow: 'DISPUTED', amount: '₩8,000,000', createdAt: '2026-02-28' }, + { id: 'ac-4', storeTitle: '합정 베이커리', type: '철거', status: 'COMPLETED', escrow: 'RELEASED', amount: '₩3,500,000', createdAt: '2026-02-15' }, +]; + +const STATUS_MAP: Record = { + DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-700' }, + SIGNED: { label: '서명 완료', color: 'bg-indigo-100 text-indigo-700' }, + ACTIVE: { label: '진행 중', color: 'bg-green-100 text-green-700' }, + COMPLETED: { label: '완료', color: 'bg-emerald-100 text-emerald-700' }, +}; + +const ESCROW_MAP: Record = { + HOLDING: { label: '보관 중', color: 'bg-blue-100 text-blue-700' }, + RELEASE_REVIEW: { label: '정산 검토', color: 'bg-purple-100 text-purple-700' }, + RELEASED: { label: '정산 완료', color: 'bg-green-100 text-green-700' }, + DISPUTED: { label: '분쟁 중', color: 'bg-red-100 text-red-700' }, +}; + +export default function AdminContractsPage() { + return ( +
+

계약/정산 관리

+

+ 계약 현황, 에스크로 정산, 분쟁을 관리합니다 +

+ + {/* 요약 카드 */} +
+
+

활성 계약

+

2

+
+
+

정산 검토 대기

+

1

+
+
+

분쟁 진행 중

+

1

+
+
+

에스크로 총 보관액

+

₩63M

+
+
+ + {/* 계약 테이블 */} +
+ + + + + + + + + + + + + + {SAMPLE_CONTRACTS.map((c) => { + const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' }; + const escrowInfo = ESCROW_MAP[c.escrow] ?? { label: c.escrow, color: 'bg-gray-100 text-gray-700' }; + return ( + + + + + + + + + + ); + })} + +
매장유형계약 상태에스크로금액생성일액션
{c.storeTitle}{c.type} + + {statusInfo.label} + + + + {escrowInfo.label} + + {c.amount}{c.createdAt} + {c.escrow === 'RELEASE_REVIEW' && ( +
+ + +
+ )} + {c.escrow === 'DISPUTED' && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx new file mode 100644 index 0000000..2ef1bbd --- /dev/null +++ b/apps/web/src/app/admin/layout.tsx @@ -0,0 +1,33 @@ +import Link from 'next/link'; + +const ADMIN_NAV = [ + { href: '/admin', label: '대시보드' }, + { href: '/admin/stores', label: '매장 검토' }, + { href: '/admin/vendors', label: '업체 인증' }, + { href: '/admin/subsidies', label: '지원금 검토' }, + { href: '/admin/contracts', label: '계약/정산' }, +]; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx new file mode 100644 index 0000000..863882f --- /dev/null +++ b/apps/web/src/app/admin/page.tsx @@ -0,0 +1,119 @@ +import Link from 'next/link'; + +export default function AdminDashboardPage() { + return ( +
+

운영자 대시보드

+

Re:Link 운영 현황을 한눈에 확인합니다

+ + {/* KPI 요약 */} +
+ + + + +
+ + {/* 검토 큐 */} +
+
+
+

매장 검토 대기

+ + 전체 보기 + +
+
+ + + +
+
+ +
+
+

업체 인증 대기

+ + 전체 보기 + +
+
+ + +
+
+ +
+
+

지원금 검토 대기

+ + 전체 보기 + +
+
+ +
+
+ +
+
+

정산 검토 대기

+ + 전체 보기 + +
+
+ +
+
+
+ + {/* 최근 활동 */} +
+

최근 감사 로그

+
+ {[ + { action: '매장 승인', target: '강남역 카페 양도', actor: '김운영', time: '10분 전' }, + { action: '업체 인증 승인', target: '(주)클린철거', actor: '이운영', time: '1시간 전' }, + { action: '에스크로 입금 확인', target: '선릉역 한식당 계약', actor: 'SYSTEM', time: '2시간 전' }, + { action: '매칭 요청 수락', target: '홍대 디저트카페', actor: '박운영', time: '3시간 전' }, + ].map((log, i) => ( +
+
+ {log.action} + {log.target} +
+
+ {log.actor} + {log.time} +
+
+ ))} +
+
+
+ ); +} + +function KPICard({ label, value, change }: { label: string; value: string; change: string }) { + return ( +
+

{label}

+

{value}

+ {change &&

{change}

} +
+ ); +} + +function QueueItem({ title, type, time }: { title: string; type: string; time: string }) { + return ( +
+
+
+ {title} + {type} +
+ {time} +
+ ); +} diff --git a/apps/web/src/app/admin/stores/page.tsx b/apps/web/src/app/admin/stores/page.tsx new file mode 100644 index 0000000..f1e3c7b --- /dev/null +++ b/apps/web/src/app/admin/stores/page.tsx @@ -0,0 +1,79 @@ +const SAMPLE_STORES = [ + { id: 's-1', title: '강남역 치킨집', region: '강남구', industry: '한식', status: 'SUBMITTED', owner: '김폐업', submittedAt: '2026-03-07 09:30' }, + { id: 's-2', title: '선릉역 중식당', region: '강남구', industry: '중식', status: 'SUBMITTED', owner: '이폐업', submittedAt: '2026-03-07 06:15' }, + { id: 's-3', title: '논현동 베이커리', region: '강남구', industry: '카페', status: 'SUBMITTED', owner: '박폐업', submittedAt: '2026-03-06 18:00' }, + { id: 's-4', title: '홍대 파스타집', region: '마포구', industry: '양식', status: 'APPROVED', owner: '최폐업', submittedAt: '2026-03-05 14:00' }, + { id: 's-5', title: '합정 디저트카페', region: '마포구', industry: '카페', status: 'REJECTED', owner: '정폐업', submittedAt: '2026-03-04 10:00' }, +]; + +const STATUS_MAP: Record = { + SUBMITTED: { label: '검토 대기', color: 'bg-yellow-100 text-yellow-700' }, + APPROVED: { label: '승인', color: 'bg-green-100 text-green-700' }, + REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' }, + PUBLISHED: { label: '공개', color: 'bg-blue-100 text-blue-700' }, +}; + +export default function AdminStoresPage() { + return ( +
+

매장 검토

+

등록된 매장을 검토하고 승인 또는 반려합니다

+ + {/* 필터 */} +
+ {['전체', '검토 대기', '승인', '반려'].map((f) => ( + + ))} +
+ + {/* 매장 목록 */} +
+ + + + + + + + + + + + + + {SAMPLE_STORES.map((store) => { + const statusInfo = STATUS_MAP[store.status] ?? { label: store.status, color: 'bg-gray-100 text-gray-700' }; + return ( + + + + + + + + + + ); + })} + +
매장명지역업종소유자상태제출일액션
{store.title}{store.region}{store.industry}{store.owner} + + {statusInfo.label} + + {store.submittedAt} + {store.status === 'SUBMITTED' && ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/subsidies/page.tsx b/apps/web/src/app/admin/subsidies/page.tsx new file mode 100644 index 0000000..ee2e91a --- /dev/null +++ b/apps/web/src/app/admin/subsidies/page.tsx @@ -0,0 +1,75 @@ +const SAMPLE_CASES = [ + { id: 'asc-1', storeTitle: '강남역 카페', owner: '김폐업', status: 'SUBMITTED', checklist: '5/5', createdAt: '2026-03-06' }, + { id: 'asc-2', storeTitle: '선릉역 한식당', owner: '이폐업', status: 'REVIEWING', checklist: '5/5', createdAt: '2026-03-04' }, + { id: 'asc-3', storeTitle: '합정 베이커리', owner: '박폐업', status: 'APPROVED', checklist: '4/4', createdAt: '2026-03-01' }, + { id: 'asc-4', storeTitle: '논현동 분식점', owner: '최폐업', status: 'REJECTED', checklist: '3/5', createdAt: '2026-02-28' }, +]; + +const STATUS_MAP: Record = { + DOCUMENTS_PENDING: { label: '서류 준비 중', color: 'bg-gray-100 text-gray-700' }, + SUBMITTED: { label: '검토 대기', color: 'bg-yellow-100 text-yellow-700' }, + REVIEWING: { label: '검토 중', color: 'bg-blue-100 text-blue-700' }, + APPROVED: { label: '승인', color: 'bg-green-100 text-green-700' }, + REJECTED: { label: '반려', color: 'bg-red-100 text-red-700' }, +}; + +export default function AdminSubsidiesPage() { + return ( +
+

지원금 검토

+

지원금 케이스를 검토하고 승인 또는 반려합니다

+ +
+ {['전체', '검토 대기', '검토 중', '승인', '반려'].map((f) => ( + + ))} +
+ +
+ + + + + + + + + + + + + {SAMPLE_CASES.map((c) => { + const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' }; + return ( + + + + + + + + + ); + })} + +
매장신청자체크리스트상태신청일액션
{c.storeTitle}{c.owner}{c.checklist} + + {statusInfo.label} + + {c.createdAt} + {(c.status === 'SUBMITTED' || c.status === 'REVIEWING') && ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/vendors/page.tsx b/apps/web/src/app/admin/vendors/page.tsx new file mode 100644 index 0000000..20b3b92 --- /dev/null +++ b/apps/web/src/app/admin/vendors/page.tsx @@ -0,0 +1,84 @@ +const SAMPLE_VENDORS = [ + { id: 'v-1', name: '(주)클린철거', type: 'DEMOLITION', region: '강남권', status: 'APPLIED', contactName: '김담당', appliedAt: '2026-03-07 08:00' }, + { id: 'v-2', name: '모던인테리어', type: 'INTERIOR', region: '마포권', status: 'APPLIED', contactName: '이담당', appliedAt: '2026-03-06 14:30' }, + { id: 'v-3', name: '서울철거공사', type: 'DEMOLITION', region: '강남권, 마포권', status: 'APPROVED', contactName: '박담당', appliedAt: '2026-03-01 10:00' }, + { id: 'v-4', name: '그린인테리어', type: 'INTERIOR', region: '강남권', status: 'SUSPENDED', contactName: '최담당', appliedAt: '2026-02-25 09:00' }, +]; + +const TYPE_LABELS: Record = { DEMOLITION: '철거', INTERIOR: '인테리어', ACQUISITION: '시설인수' }; +const STATUS_MAP: Record = { + APPLIED: { label: '심사 대기', color: 'bg-yellow-100 text-yellow-700' }, + REVIEWING: { label: '심사 중', color: 'bg-blue-100 text-blue-700' }, + APPROVED: { label: '인증됨', color: 'bg-green-100 text-green-700' }, + SUSPENDED: { label: '중지', color: 'bg-red-100 text-red-700' }, + REJECTED: { label: '반려', color: 'bg-gray-100 text-gray-700' }, +}; + +export default function AdminVendorsPage() { + return ( +
+

업체 인증 관리

+

인증 신청을 검토하고 승인·반려·중지를 관리합니다

+ +
+ {['전체', '심사 대기', '인증됨', '중지'].map((f) => ( + + ))} +
+ +
+ + + + + + + + + + + + + + {SAMPLE_VENDORS.map((vendor) => { + const statusInfo = STATUS_MAP[vendor.status] ?? { label: vendor.status, color: 'bg-gray-100 text-gray-700' }; + return ( + + + + + + + + + + ); + })} + +
업체명유형서비스 지역담당자상태신청일액션
{vendor.name}{TYPE_LABELS[vendor.type] ?? vendor.type}{vendor.region}{vendor.contactName} + + {statusInfo.label} + + {vendor.appliedAt} + {vendor.status === 'APPLIED' && ( +
+ + +
+ )} + {vendor.status === 'APPROVED' && ( + + )} + {vendor.status === 'SUSPENDED' && ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/api/v1/master-data/route.ts b/apps/web/src/app/api/v1/master-data/route.ts new file mode 100644 index 0000000..2696677 --- /dev/null +++ b/apps/web/src/app/api/v1/master-data/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { createPrismaClient } from '@relink/database'; + +const prisma = createPrismaClient(); + +export async function GET() { + const [regions, industries] = await Promise.all([ + prisma.regionHierarchy.findMany({ + where: { isActive: true, isBetaEnabled: true }, + select: { + code: true, + nameKo: true, + regionType: true, + parentId: true, + depth: true, + isBetaEnabled: true, + }, + orderBy: [{ depth: 'asc' }, { sortOrder: 'asc' }], + }), + prisma.industryTaxonomy.findMany({ + where: { isActive: true, isBetaEnabled: true, isLeaf: true }, + select: { + code: true, + nameKo: true, + depth: true, + isLeaf: true, + isBetaEnabled: true, + }, + orderBy: { sortOrder: 'asc' }, + }), + ]); + + return NextResponse.json({ + ok: true, + data: { regions, industries }, + }); +} diff --git a/apps/web/src/app/api/v1/stores/[id]/submit/route.ts b/apps/web/src/app/api/v1/stores/[id]/submit/route.ts new file mode 100644 index 0000000..bd3140f --- /dev/null +++ b/apps/web/src/app/api/v1/stores/[id]/submit/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createPrismaClient } from '@relink/database'; +import { submitStoreService } from '@/services/store-service'; + +const prisma = createPrismaClient(); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const body = await request.json().catch(() => ({})); + const actorUserId = (body as Record).actorUserId; + + if (!actorUserId) { + return NextResponse.json( + { ok: false, error: { code: 'VALIDATION_ERROR', message: 'actorUserId는 필수입니다.' } }, + { status: 400 }, + ); + } + + const result = await submitStoreService(prisma, id, actorUserId); + + if (!result.ok) { + const status = result.error.code === 'NOT_FOUND' ? 404 : 422; + return NextResponse.json({ ok: false, error: result.error }, { status }); + } + + return NextResponse.json({ ok: true, data: result.value }); +} diff --git a/apps/web/src/app/api/v1/stores/route.ts b/apps/web/src/app/api/v1/stores/route.ts new file mode 100644 index 0000000..da5c1dc --- /dev/null +++ b/apps/web/src/app/api/v1/stores/route.ts @@ -0,0 +1,59 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createPrismaClient } from '@relink/database'; +import { createStoreDraftService } from '@/services/store-service'; +import { z } from 'zod'; + +const prisma = createPrismaClient(); + +const createStoreSchema = z.object({ + ownerUserId: z.string().min(1), + listingTitle: z.string().min(1), + industryLeafCode: z.string().min(1), + regionClusterCode: z.string().min(1), + roadAddress: z.string().min(1), + detailAddress: z.string().optional(), + lease: z + .object({ + depositAmount: z.number().min(0), + monthlyRentAmount: z.number().min(0), + premiumAmount: z.number().min(0), + maintenanceFeeAmount: z.number().min(0).optional(), + remainingLeaseMonths: z.number().int().min(0).optional(), + transferable: z.boolean().optional(), + }) + .optional(), + facility: z + .object({ + exclusiveAreaSqm: z.number().positive(), + seatCount: z.number().int().min(0), + floorLevel: z.number().int().optional(), + hasGas: z.boolean().optional(), + hasDrainage: z.boolean().optional(), + hasDuct: z.boolean().optional(), + electricCapacityKw: z.number().min(0).optional(), + kitchenEquipmentSummary: z.string().optional(), + parkingCount: z.number().int().min(0).optional(), + }) + .optional(), +}); + +export async function POST(request: NextRequest) { + const body = await request.json(); + const parsed = createStoreSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { ok: false, error: { code: 'VALIDATION_ERROR', message: parsed.error.message } }, + { status: 400 }, + ); + } + + const result = await createStoreDraftService(prisma, parsed.data); + + if (!result.ok) { + const status = result.error.code === 'VALIDATION_ERROR' ? 400 : 422; + return NextResponse.json({ ok: false, error: result.error }, { status }); + } + + return NextResponse.json({ ok: true, data: result.value }, { status: 201 }); +} diff --git a/apps/web/src/app/contracts/page.tsx b/apps/web/src/app/contracts/page.tsx new file mode 100644 index 0000000..82bc7df --- /dev/null +++ b/apps/web/src/app/contracts/page.tsx @@ -0,0 +1,130 @@ +import Link from 'next/link'; + +const SAMPLE_CONTRACTS = [ + { + id: 'ct-1', + storeTitle: '강남역 카페 양도', + contractType: 'ACQUISITION', + status: 'DRAFT', + escrowStatus: 'NOT_STARTED', + createdAt: '2026-03-06', + }, + { + id: 'ct-2', + storeTitle: '선릉역 한식당', + contractType: 'DEMOLITION', + status: 'ACTIVE', + escrowStatus: 'HOLDING', + createdAt: '2026-03-02', + }, + { + id: 'ct-3', + storeTitle: '합정 베이커리', + contractType: 'INTERIOR', + status: 'COMPLETED', + escrowStatus: 'RELEASED', + createdAt: '2026-02-20', + }, +]; + +const CONTRACT_TYPE_LABELS: Record = { + ACQUISITION: '시설인수', + DEMOLITION: '철거', + INTERIOR: '인테리어', +}; + +const STATUS_MAP: Record = { + DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-700' }, + GENERATED: { label: '생성됨', color: 'bg-blue-100 text-blue-700' }, + SIGNING: { label: '서명 중', color: 'bg-yellow-100 text-yellow-700' }, + SIGNED: { label: '서명 완료', color: 'bg-indigo-100 text-indigo-700' }, + ACTIVE: { label: '진행 중', color: 'bg-green-100 text-green-700' }, + COMPLETED: { label: '완료', color: 'bg-emerald-100 text-emerald-700' }, + CANCELLED: { label: '취소', color: 'bg-red-100 text-red-700' }, +}; + +const ESCROW_MAP: Record = { + NOT_STARTED: { label: '미시작', color: 'text-gray-500' }, + DEPOSIT_PENDING: { label: '입금 대기', color: 'text-yellow-600' }, + HOLDING: { label: '보관 중', color: 'text-blue-600' }, + RELEASE_REVIEW: { label: '정산 검토', color: 'text-purple-600' }, + RELEASED: { label: '정산 완료', color: 'text-green-600' }, + REFUNDED: { label: '환불됨', color: 'text-orange-600' }, + DISPUTED: { label: '분쟁 중', color: 'text-red-600' }, +}; + +export default function ContractsPage() { + return ( +
+

계약 관리

+

계약, 에스크로, 검수 현황을 관리합니다

+ + {/* 요약 */} +
+ + + + +
+ + {/* 계약 목록 */} +
+ {SAMPLE_CONTRACTS.map((contract) => { + const statusInfo = STATUS_MAP[contract.status] ?? { label: contract.status, color: 'bg-gray-100 text-gray-700' }; + const escrowInfo = ESCROW_MAP[contract.escrowStatus] ?? { label: contract.escrowStatus, color: 'text-gray-500' }; + return ( +
+
+
+
+

{contract.storeTitle}

+ + {CONTRACT_TYPE_LABELS[contract.contractType] ?? contract.contractType} + +
+

생성일: {contract.createdAt}

+
+ + {statusInfo.label} + +
+ +
+
+ 에스크로: + {escrowInfo.label} +
+
+ + {contract.status === 'ACTIVE' && ( +
+ + +
+ )} +
+ ); + })} +
+ +
+

+ 새 계약은{' '} + + 수락된 매칭 요청 + + 에서 생성됩니다 +

+
+
+ ); +} + +function SummaryCard({ label, value }: { label: string; value: string }) { + return ( +
+

{value}

+

{label}

+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..281d6b3 --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import './globals.css'; + +export const metadata: Metadata = { + title: 'Re:Link', + description: 'Re:Link - 폐업 · 양도 · 창업을 잇는 중개 플랫폼', +}; + +function Navigation() { + return ( + + ); +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + ); +} diff --git a/apps/web/src/app/matching/page.tsx b/apps/web/src/app/matching/page.tsx new file mode 100644 index 0000000..cbf3cf8 --- /dev/null +++ b/apps/web/src/app/matching/page.tsx @@ -0,0 +1,95 @@ +import Link from 'next/link'; + +const SAMPLE_REQUESTS = [ + { + id: 'mr-1', + storeTitle: '강남역 카페 양도', + matchType: 'ACQUISITION', + status: 'OPEN', + createdAt: '2026-03-05', + message: '매장 인수 희망합니다.', + }, + { + id: 'mr-2', + storeTitle: '선릉역 한식당 양도', + matchType: 'DEMOLITION', + status: 'ACCEPTED', + createdAt: '2026-03-03', + message: '철거 견적 요청드립니다.', + }, + { + id: 'mr-3', + storeTitle: '홍대입구 디저트카페', + matchType: 'INTERIOR', + status: 'OPEN', + createdAt: '2026-03-06', + message: '인테리어 리모델링 상담 원합니다.', + }, +]; + +const MATCH_TYPE_LABELS: Record = { + ACQUISITION: '인수', + DEMOLITION: '철거', + INTERIOR: '인테리어', +}; + +const STATUS_LABELS: Record = { + OPEN: { label: '대기 중', color: 'bg-blue-100 text-blue-700' }, + REVIEWING: { label: '검토 중', color: 'bg-yellow-100 text-yellow-700' }, + ACCEPTED: { label: '수락됨', color: 'bg-green-100 text-green-700' }, + REJECTED: { label: '거절됨', color: 'bg-red-100 text-red-700' }, +}; + +export default function MatchingPage() { + return ( +
+

매칭 요청

+

매장과의 매칭 요청 현황을 관리합니다

+ +
+ {SAMPLE_REQUESTS.map((req) => { + const statusInfo = STATUS_LABELS[req.status] ?? { label: req.status, color: 'bg-gray-100 text-gray-700' }; + return ( +
+
+
+
+

{req.storeTitle}

+ + {MATCH_TYPE_LABELS[req.matchType] ?? req.matchType} + +
+

{req.message}

+

요청일: {req.createdAt}

+
+ + {statusInfo.label} + +
+ {req.status === 'ACCEPTED' && ( +
+ + 계약 진행하기 → + +
+ )} +
+ ); + })} +
+ +
+

+ 새로운 매칭 요청을 보내려면{' '} + + 매장 검색 + + 에서 원하는 매장을 찾아보세요 +

+
+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 0000000..6059d48 --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -0,0 +1,86 @@ +import Link from 'next/link'; + +export default function HomePage() { + return ( +
+
+

Re:Link

+

폐업 · 양도 · 창업을 잇는 중개 플랫폼

+

+ 매장 정보 1회 등록으로 창업자·철거업체·인테리어업체를 동시 연결합니다 +

+
+ + 매장 둘러보기 + + + 매장 등록하기 + +
+
+ +
+
+
🏪
+

폐업자

+

+ 매장 정보를 등록하면 철거비 절감, 시설 처분, 지원금 신청까지 한 번에 해결됩니다. +

+ + 매장 등록 → + +
+ +
+
🔍
+

창업자

+

+ 검증된 매장을 검색하고 매칭 요청을 보내 시설 인수와 인테리어 비용을 절감하세요. +

+ + 매장 검색 → + +
+ +
+
🏗️
+

철거·인테리어 업체

+

+ 인증 업체로 등록하면 안정적인 수주와 정산을 보장받을 수 있습니다. +

+ + 업체 인증 → + +
+
+ +
+
+

정부 지원금 가이드

+

+ 폐업 관련 정부 지원금 자격을 확인하고, 체크리스트와 서류 준비를 도와드립니다. +

+ + 지원금 확인 → + +
+ +
+

안전한 거래

+

+ 표준 계약서, 에스크로 결제, 검수 승인 시스템으로 안전한 거래를 보장합니다. +

+ + 계약 관리 → + +
+
+
+ ); +} diff --git a/apps/web/src/app/stores/[id]/page.tsx b/apps/web/src/app/stores/[id]/page.tsx new file mode 100644 index 0000000..02615be --- /dev/null +++ b/apps/web/src/app/stores/[id]/page.tsx @@ -0,0 +1,89 @@ +import Link from 'next/link'; + +export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + return ( +
+
+ + ← 매장 목록으로 + +
+ +
+
+
+

매장 상세 정보

+

매장 ID: {id}

+
+ + 거래 가능 + +
+ + {/* 기본 정보 */} +
+

기본 정보

+
+ + + + +
+
+ + {/* 임대 정보 */} +
+

임대 정보

+
+ + + + +
+
+ + {/* 시설 정보 */} +
+

시설 정보

+
+ + +
+
+

시설 설명

+

+ 에스프레소 머신, 그라인더, 제빙기 포함. 인테리어 2024년 리뉴얼 완료. 좌석 30석. +

+
+
+ + {/* 액션 버튼 */} +
+ + 매칭 요청 보내기 + + + 지원금 확인 + +
+
+
+ ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/apps/web/src/app/stores/new/page.tsx b/apps/web/src/app/stores/new/page.tsx new file mode 100644 index 0000000..78baeba --- /dev/null +++ b/apps/web/src/app/stores/new/page.tsx @@ -0,0 +1,155 @@ +import Link from 'next/link'; + +export default function NewStorePage() { + return ( +
+
+ + ← 매장 목록으로 + +
+ +

매장 등록

+

+ 매장 정보를 등록하면 창업자, 철거업체, 인테리어업체와 매칭됩니다 +

+ +
+ {/* 기본 정보 */} +
+

기본 정보

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + {/* 임대 정보 */} +
+

임대 정보

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + {/* 시설 정보 */} +
+

시설 정보

+
+
+
+ + +
+
+ + +
+
+
+ +