feat: Re:Link MVP 초기 구현 - 도메인/서비스/프론트엔드 전체

- 모노레포 구조 (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 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-03-07 17:39:56 +09:00
commit 16bd2cb92a
170 changed files with 23628 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@relink/analytics",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@relink/shared": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Base analytics event interface.
*/
export interface AnalyticsEvent {
readonly name: string;
readonly timestamp: Date;
readonly properties: Record<string, unknown>;
}
/**
* Creates a new analytics event with the current timestamp.
*/
export function createEvent(
name: string,
properties: Record<string, unknown> = {},
): AnalyticsEvent {
return {
name,
timestamp: new Date(),
properties,
};
}
+6
View File
@@ -0,0 +1,6 @@
/**
* @relink/analytics - Event schemas and KPI calculations
*/
export type { AnalyticsEvent } from './event.js';
export { createEvent } from './event.js';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
external: ['@relink/shared'],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@relink/application",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@relink/domain": "workspace:*",
"@relink/shared": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* @relink/application - Use cases and application services
*
* Orchestrates domain entities and defines application-level business rules.
*/
export type { UseCase } from './use-case.js';
+8
View File
@@ -0,0 +1,8 @@
import type { Result } from '@relink/shared';
/**
* Base use case interface. All application use cases implement this.
*/
export interface UseCase<TInput, TOutput, TError = Error> {
execute(input: TInput): Promise<Result<TOutput, TError>>;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
external: ['@relink/domain', '@relink/shared'],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+1
View File
@@ -0,0 +1 @@
DATABASE_URL="postgresql://relink:relink_dev@localhost:5432/relink_dev"
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@relink/database",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "tsx seeds/seed.ts"
},
"prisma": {
"seed": "tsx seeds/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.1.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"prisma": "^6.1.0",
"tsup": "^8.3.5",
"tsx": "^4.19.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
code,name_ko,parent_code,depth,sort_order,is_leaf,is_active,is_beta_enabled
FNB,음식점/카페,,0,1,false,true,true
FNB.CAFE,카페,FNB,1,1,true,true,true
FNB.BAKERY,베이커리,FNB,1,2,true,true,true
FNB.KOREAN,한식,FNB,1,3,true,true,true
FNB.CHICKEN,치킨,FNB,1,4,true,true,true
FNB.BAR,주점,FNB,1,5,true,true,true
FNB.DESSERT,디저트,FNB,1,6,true,true,true
FNB.FASTCASUAL,간편식/패스트캐주얼,FNB,1,7,true,true,true
1 code name_ko parent_code depth sort_order is_leaf is_active is_beta_enabled
2 FNB 음식점/카페 0 1 false true true
3 FNB.CAFE 카페 FNB 1 1 true true true
4 FNB.BAKERY 베이커리 FNB 1 2 true true true
5 FNB.KOREAN 한식 FNB 1 3 true true true
6 FNB.CHICKEN 치킨 FNB 1 4 true true true
7 FNB.BAR 주점 FNB 1 5 true true true
8 FNB.DESSERT 디저트 FNB 1 6 true true true
9 FNB.FASTCASUAL 간편식/패스트캐주얼 FNB 1 7 true true true
@@ -0,0 +1,13 @@
code,name_ko,full_name_ko,region_type,parent_code,depth,path_code,sort_order,is_active,is_beta_enabled,latitude,longitude
KR,대한민국,대한민국,COUNTRY,,0,KR,1,true,false,,
KR.SEOUL,서울특별시,서울특별시,SIDO,KR,1,KR.SEOUL,1,true,false,37.5665,126.9780
KR.SEOUL.GANGNAM,강남구,서울특별시 강남구,SIGUNGU,KR.SEOUL,2,KR.SEOUL.GANGNAM,1,true,false,37.5172,127.0473
KR.SEOUL.MAPO,마포구,서울특별시 마포구,SIGUNGU,KR.SEOUL,2,KR.SEOUL.MAPO,2,true,false,37.5663,126.9014
KR.SEOUL.GANGNAM.YEOKSAM,역삼,서울특별시 강남구 역삼,COMMERCIAL_AREA,KR.SEOUL.GANGNAM,3,KR.SEOUL.GANGNAM.YEOKSAM,1,true,true,37.5007,127.0365
KR.SEOUL.GANGNAM.SEOLLEUNG,선릉,서울특별시 강남구 선릉,COMMERCIAL_AREA,KR.SEOUL.GANGNAM,3,KR.SEOUL.GANGNAM.SEOLLEUNG,2,true,true,37.5045,127.0490
KR.SEOUL.GANGNAM.NONHYEON,논현,서울특별시 강남구 논현,COMMERCIAL_AREA,KR.SEOUL.GANGNAM,3,KR.SEOUL.GANGNAM.NONHYEON,3,true,true,37.5112,127.0276
KR.SEOUL.MAPO.HONGDAE,홍대,서울특별시 마포구 홍대,COMMERCIAL_AREA,KR.SEOUL.MAPO,3,KR.SEOUL.MAPO.HONGDAE,1,true,true,37.5563,126.9237
KR.SEOUL.MAPO.HAPJEONG,합정,서울특별시 마포구 합정,COMMERCIAL_AREA,KR.SEOUL.MAPO,3,KR.SEOUL.MAPO.HAPJEONG,2,true,true,37.5496,126.9139
KR.SEOUL.MAPO.YEONNAM,연남,서울특별시 마포구 연남,COMMERCIAL_AREA,KR.SEOUL.MAPO,3,KR.SEOUL.MAPO.YEONNAM,3,true,true,37.5660,126.9247
KR.BETA.GANGNAM_CORE,강남권 베타 클러스터,강남권 베타 클러스터,BETA_CLUSTER,KR.SEOUL.GANGNAM,3,KR.BETA.GANGNAM_CORE,10,true,true,37.5058,127.0370
KR.BETA.MAPO_CORE,마포권 베타 클러스터,마포권 베타 클러스터,BETA_CLUSTER,KR.SEOUL.MAPO,3,KR.BETA.MAPO_CORE,10,true,true,37.5573,126.9192
1 code name_ko full_name_ko region_type parent_code depth path_code sort_order is_active is_beta_enabled latitude longitude
2 KR 대한민국 대한민국 COUNTRY 0 KR 1 true false
3 KR.SEOUL 서울특별시 서울특별시 SIDO KR 1 KR.SEOUL 1 true false 37.5665 126.9780
4 KR.SEOUL.GANGNAM 강남구 서울특별시 강남구 SIGUNGU KR.SEOUL 2 KR.SEOUL.GANGNAM 1 true false 37.5172 127.0473
5 KR.SEOUL.MAPO 마포구 서울특별시 마포구 SIGUNGU KR.SEOUL 2 KR.SEOUL.MAPO 2 true false 37.5663 126.9014
6 KR.SEOUL.GANGNAM.YEOKSAM 역삼 서울특별시 강남구 역삼 COMMERCIAL_AREA KR.SEOUL.GANGNAM 3 KR.SEOUL.GANGNAM.YEOKSAM 1 true true 37.5007 127.0365
7 KR.SEOUL.GANGNAM.SEOLLEUNG 선릉 서울특별시 강남구 선릉 COMMERCIAL_AREA KR.SEOUL.GANGNAM 3 KR.SEOUL.GANGNAM.SEOLLEUNG 2 true true 37.5045 127.0490
8 KR.SEOUL.GANGNAM.NONHYEON 논현 서울특별시 강남구 논현 COMMERCIAL_AREA KR.SEOUL.GANGNAM 3 KR.SEOUL.GANGNAM.NONHYEON 3 true true 37.5112 127.0276
9 KR.SEOUL.MAPO.HONGDAE 홍대 서울특별시 마포구 홍대 COMMERCIAL_AREA KR.SEOUL.MAPO 3 KR.SEOUL.MAPO.HONGDAE 1 true true 37.5563 126.9237
10 KR.SEOUL.MAPO.HAPJEONG 합정 서울특별시 마포구 합정 COMMERCIAL_AREA KR.SEOUL.MAPO 3 KR.SEOUL.MAPO.HAPJEONG 2 true true 37.5496 126.9139
11 KR.SEOUL.MAPO.YEONNAM 연남 서울특별시 마포구 연남 COMMERCIAL_AREA KR.SEOUL.MAPO 3 KR.SEOUL.MAPO.YEONNAM 3 true true 37.5660 126.9247
12 KR.BETA.GANGNAM_CORE 강남권 베타 클러스터 강남권 베타 클러스터 BETA_CLUSTER KR.SEOUL.GANGNAM 3 KR.BETA.GANGNAM_CORE 10 true true 37.5058 127.0370
13 KR.BETA.MAPO_CORE 마포권 베타 클러스터 마포권 베타 클러스터 BETA_CLUSTER KR.SEOUL.MAPO 3 KR.BETA.MAPO_CORE 10 true true 37.5573 126.9192
+157
View File
@@ -0,0 +1,157 @@
import { PrismaClient } from '@prisma/client';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const prisma = new PrismaClient();
interface RegionRow {
code: string;
name_ko: string;
full_name_ko: string;
region_type: string;
parent_code: string;
depth: string;
path_code: string;
sort_order: string;
is_active: string;
is_beta_enabled: string;
latitude: string;
longitude: string;
}
interface IndustryRow {
code: string;
name_ko: string;
parent_code: string;
depth: string;
sort_order: string;
is_leaf: string;
is_active: string;
is_beta_enabled: string;
}
function parseCsv(filePath: string): Record<string, string>[] {
const content = readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n');
const headers = lines[0]!.split(',');
return lines.slice(1).map((line) => {
const values = line.split(',');
const row: Record<string, string> = {};
for (let i = 0; i < headers.length; i++) {
row[headers[i]!] = values[i] ?? '';
}
return row;
});
}
async function seedRegions(): Promise<void> {
const csvPath = resolve(__dirname, 'master-data/regions.v1.csv');
const rows = parseCsv(csvPath) as unknown as RegionRow[];
// code → id 매핑을 위한 맵
const codeToId = new Map<string, bigint>();
// depth 순으로 정렬하여 부모를 먼저 upsert
const sorted = [...rows].sort((a, b) => parseInt(a.depth) - parseInt(b.depth));
for (const row of sorted) {
const parentId = row.parent_code ? (codeToId.get(row.parent_code) ?? null) : null;
const result = await prisma.regionHierarchy.upsert({
where: { code: row.code },
update: {
nameKo: row.name_ko,
fullNameKo: row.full_name_ko || null,
regionType: row.region_type as never,
parentId,
depth: parseInt(row.depth),
pathCode: row.path_code,
sortOrder: parseInt(row.sort_order),
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
latitude: row.latitude ? parseFloat(row.latitude) : null,
longitude: row.longitude ? parseFloat(row.longitude) : null,
},
create: {
code: row.code,
nameKo: row.name_ko,
fullNameKo: row.full_name_ko || null,
regionType: row.region_type as never,
parentId,
depth: parseInt(row.depth),
pathCode: row.path_code,
sortOrder: parseInt(row.sort_order),
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
latitude: row.latitude ? parseFloat(row.latitude) : null,
longitude: row.longitude ? parseFloat(row.longitude) : null,
},
});
codeToId.set(row.code, result.id);
}
console.log(`Seeded ${sorted.length} regions`);
}
async function seedIndustries(): Promise<void> {
const csvPath = resolve(__dirname, 'master-data/industries.v1.csv');
const rows = parseCsv(csvPath) as unknown as IndustryRow[];
const codeToId = new Map<string, bigint>();
const sorted = [...rows].sort((a, b) => parseInt(a.depth) - parseInt(b.depth));
for (const row of sorted) {
const parentId = row.parent_code ? (codeToId.get(row.parent_code) ?? null) : null;
const result = await prisma.industryTaxonomy.upsert({
where: { code: row.code },
update: {
nameKo: row.name_ko,
parentId,
depth: parseInt(row.depth),
sortOrder: parseInt(row.sort_order),
isLeaf: row.is_leaf === 'true',
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
},
create: {
code: row.code,
nameKo: row.name_ko,
parentId,
depth: parseInt(row.depth),
sortOrder: parseInt(row.sort_order),
isLeaf: row.is_leaf === 'true',
isActive: row.is_active === 'true',
isBetaEnabled: row.is_beta_enabled === 'true',
},
});
codeToId.set(row.code, result.id);
}
console.log(`Seeded ${sorted.length} industries`);
}
async function main(): Promise<void> {
console.log('Starting seed...');
await seedRegions();
await seedIndustries();
console.log('Seed completed successfully');
}
main()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+26
View File
@@ -0,0 +1,26 @@
import { PrismaClient } from '@prisma/client';
let prismaInstance: PrismaClient | null = null;
/**
* Creates or returns a singleton PrismaClient instance.
* In development, reuses the instance across hot-reloads via a global variable.
*/
export function createPrismaClient(): PrismaClient {
if (prismaInstance) {
return prismaInstance;
}
const client = new PrismaClient({
log:
process.env['NODE_ENV'] === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env['NODE_ENV'] !== 'production') {
prismaInstance = client;
}
return client;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* @relink/database - Prisma client and database utilities
*/
export { createPrismaClient } from './client.js';
export {
getTestPrismaClient,
setupTestDatabase,
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from './test-helpers.js';
+194
View File
@@ -0,0 +1,194 @@
import { PrismaClient } from '@prisma/client';
import { execSync } from 'node:child_process';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = resolve(__dirname, '../prisma/schema.prisma');
const TEST_DATABASE_URL =
process.env['DATABASE_TEST_URL'] ??
'postgresql://relink:relink_test@localhost:5433/relink_test';
let testClient: PrismaClient | null = null;
export function getTestPrismaClient(): PrismaClient {
if (testClient) {
return testClient;
}
testClient = new PrismaClient({
datasourceUrl: TEST_DATABASE_URL,
log: ['error'],
});
return testClient;
}
export async function setupTestDatabase(): Promise<PrismaClient> {
execSync(`DATABASE_URL="${TEST_DATABASE_URL}" npx prisma db push --schema="${SCHEMA_PATH}" --skip-generate --accept-data-loss`, {
stdio: 'pipe',
cwd: resolve(__dirname, '..'),
});
const client = getTestPrismaClient();
await client.$connect();
return client;
}
export async function teardownTestDatabase(): Promise<void> {
if (testClient) {
await testClient.$disconnect();
testClient = null;
}
}
const TABLE_NAMES = [
'signature_evidences',
'contract_versions',
'escrow_transactions',
'inspection_records',
'dispute_cases',
'contracts',
'subsidy_documents',
'subsidy_checklist_items',
'subsidy_cases',
'match_requests',
'store_photos',
'store_lifecycles',
'store_facilities',
'store_leases',
'stores',
'vendor_coverage_regions',
'vendor_certifications',
'vendors',
'user_consents',
'user_profiles',
'users',
'outbox_events',
'event_logs',
'audit_logs',
'idempotency_keys',
'feature_flags',
'policy_versions',
'industry_taxonomies',
'region_hierarchies',
];
export async function cleanAllTables(prisma: PrismaClient): Promise<void> {
for (const table of TABLE_NAMES) {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
}
}
export async function seedTestMasterData(prisma: PrismaClient): Promise<void> {
// 지역 마스터 데이터
const kr = await prisma.regionHierarchy.create({
data: {
code: 'KR',
nameKo: '대한민국',
regionType: 'COUNTRY',
depth: 0,
pathCode: 'KR',
sortOrder: 1,
isActive: true,
isBetaEnabled: false,
},
});
const seoul = await prisma.regionHierarchy.create({
data: {
code: 'KR.SEOUL',
nameKo: '서울특별시',
regionType: 'SIDO',
parentId: kr.id,
depth: 1,
pathCode: 'KR.SEOUL',
sortOrder: 1,
isActive: true,
isBetaEnabled: false,
},
});
const gangnam = await prisma.regionHierarchy.create({
data: {
code: 'KR.SEOUL.GANGNAM',
nameKo: '강남구',
regionType: 'SIGUNGU',
parentId: seoul.id,
depth: 2,
pathCode: 'KR.SEOUL.GANGNAM',
sortOrder: 1,
isActive: true,
isBetaEnabled: false,
},
});
await prisma.regionHierarchy.create({
data: {
code: 'KR.BETA.GANGNAM_CORE',
nameKo: '강남권 베타 클러스터',
regionType: 'BETA_CLUSTER',
parentId: gangnam.id,
depth: 3,
pathCode: 'KR.BETA.GANGNAM_CORE',
sortOrder: 10,
isActive: true,
isBetaEnabled: true,
},
});
// 비베타 지역 (테스트용)
await prisma.regionHierarchy.create({
data: {
code: 'KR.BUSAN',
nameKo: '부산광역시',
regionType: 'SIDO',
parentId: kr.id,
depth: 1,
pathCode: 'KR.BUSAN',
sortOrder: 2,
isActive: true,
isBetaEnabled: false,
},
});
// 업종 마스터 데이터
const fnb = await prisma.industryTaxonomy.create({
data: {
code: 'FNB',
nameKo: '음식점/카페',
depth: 0,
sortOrder: 1,
isLeaf: false,
isActive: true,
isBetaEnabled: true,
},
});
await prisma.industryTaxonomy.create({
data: {
code: 'FNB.CAFE',
nameKo: '카페',
parentId: fnb.id,
depth: 1,
sortOrder: 1,
isLeaf: true,
isActive: true,
isBetaEnabled: true,
},
});
await prisma.industryTaxonomy.create({
data: {
code: 'FNB.KOREAN',
nameKo: '한식',
parentId: fnb.id,
depth: 1,
sortOrder: 3,
isLeaf: true,
isActive: true,
isBetaEnabled: true,
},
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@relink/domain",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@relink/shared": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { checkIdempotency } from '../check-idempotency.js';
describe('checkIdempotency', () => {
// U024: IdempotencyKey 중복 처리 방지
it('U024-1: 새로운 키는 isNew=true 반환', () => {
const result = checkIdempotency({
idempotencyKey: 'webhook-evt-123',
alreadyProcessed: false,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.isNew).toBe(true);
expect(result.value.idempotencyKey).toBe('webhook-evt-123');
}
});
it('U024-2: 이미 처리된 키는 isNew=false 반환', () => {
const result = checkIdempotency({
idempotencyKey: 'webhook-evt-123',
alreadyProcessed: true,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.isNew).toBe(false);
}
});
it('U024-3: 빈 키는 실패', () => {
const result = checkIdempotency({
idempotencyKey: '',
alreadyProcessed: false,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U024-4: 공백 키도 실패', () => {
const result = checkIdempotency({
idempotencyKey: ' ',
alreadyProcessed: false,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
});
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import { createContract, type CreateContractInput } from '../create-contract.js';
function validInput(overrides: Partial<CreateContractInput> = {}): CreateContractInput {
return {
matchRequestStatus: 'ACCEPTED',
contractType: 'ACQUISITION',
templateCode: 'ACQ_STANDARD_V1',
policyVersionId: 'pv-1',
...overrides,
};
}
describe('createContract', () => {
// U021: 수락된 매칭만 계약 생성 가능
it('U021-1: ACCEPTED 매칭에서 계약 생성 성공', () => {
const result = createContract(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('DRAFT');
expect(result.value.contractType).toBe('ACQUISITION');
}
});
it('U021-2: OPEN 매칭에서는 계약 생성 불가', () => {
const result = createContract(validInput({ matchRequestStatus: 'OPEN' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('MATCH_NOT_ACCEPTED');
}
});
it('U021-3: REVIEWING 매칭에서는 계약 생성 불가', () => {
const result = createContract(validInput({ matchRequestStatus: 'REVIEWING' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('MATCH_NOT_ACCEPTED');
}
});
it('U021-4: REJECTED 매칭에서는 계약 생성 불가', () => {
const result = createContract(validInput({ matchRequestStatus: 'REJECTED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('MATCH_NOT_ACCEPTED');
}
});
// U022: 템플릿 버전과 정책 버전 함께 저장
it('U022-1: 템플릿 코드와 정책 버전이 결과에 포함', () => {
const result = createContract(validInput({
templateCode: 'DEM_STANDARD_V2',
policyVersionId: 'pv-5',
contractType: 'DEMOLITION',
}));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.templateCode).toBe('DEM_STANDARD_V2');
expect(result.value.policyVersionId).toBe('pv-5');
expect(result.value.contractType).toBe('DEMOLITION');
}
});
it('U022-2: 빈 템플릿 코드는 실패', () => {
const result = createContract(validInput({ templateCode: ' ' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U022-3: 빈 정책 버전은 실패', () => {
const result = createContract(validInput({ policyVersionId: '' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U022-4: INTERIOR 타입 계약 생성 성공', () => {
const result = createContract(validInput({ contractType: 'INTERIOR' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.contractType).toBe('INTERIOR');
}
});
});
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import { openDispute, type OpenDisputeInput } from '../open-dispute.js';
function validInput(overrides: Partial<OpenDisputeInput> = {}): OpenDisputeInput {
return {
currentEscrowStatus: 'HOLDING',
contractStatus: 'ACTIVE',
reasonCode: 'QUALITY_ISSUE',
description: '시공 품질이 계약 내용과 다릅니다.',
...overrides,
};
}
describe('openDispute', () => {
// U025: 분쟁 시 에스크로 DISPUTED 전환
it('U025-1: ACTIVE 계약 + HOLDING 에스크로에서 분쟁 성공', () => {
const result = openDispute(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.escrowStatus).toBe('DISPUTED');
expect(result.value.reasonCode).toBe('QUALITY_ISSUE');
expect(result.value.description).toBe('시공 품질이 계약 내용과 다릅니다.');
}
});
it('U025-2: SIGNED 계약에서도 분쟁 가능', () => {
const result = openDispute(validInput({ contractStatus: 'SIGNED' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.escrowStatus).toBe('DISPUTED');
}
});
it('U025-3: RELEASE_REVIEW 에스크로에서도 분쟁 가능', () => {
const result = openDispute(validInput({ currentEscrowStatus: 'RELEASE_REVIEW' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.escrowStatus).toBe('DISPUTED');
}
});
it('U025-4: DRAFT 계약에서는 분쟁 불가', () => {
const result = openDispute(validInput({ contractStatus: 'DRAFT' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_CONTRACT_STATUS');
}
});
it('U025-5: COMPLETED 계약에서는 분쟁 불가', () => {
const result = openDispute(validInput({ contractStatus: 'COMPLETED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_CONTRACT_STATUS');
}
});
it('U025-6: NOT_STARTED 에스크로에서는 분쟁 불가', () => {
const result = openDispute(validInput({ currentEscrowStatus: 'NOT_STARTED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_ESCROW_STATUS');
}
});
it('U025-7: RELEASED 에스크로에서는 분쟁 불가', () => {
const result = openDispute(validInput({ currentEscrowStatus: 'RELEASED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_ESCROW_STATUS');
}
});
it('U025-8: 사유 코드 없으면 실패', () => {
const result = openDispute(validInput({ reasonCode: '' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U025-9: description 없이도 분쟁 가능', () => {
const result = openDispute(validInput({ description: undefined }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.description).toBeUndefined();
}
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { releaseEscrow, type ReleaseEscrowInput } from '../release-escrow.js';
function validInput(overrides: Partial<ReleaseEscrowInput> = {}): ReleaseEscrowInput {
return {
currentEscrowStatus: 'HOLDING',
hasApprovedInspection: true,
hasOpenDispute: false,
...overrides,
};
}
describe('releaseEscrow', () => {
// U023: 검수 승인 전에는 정산 해제 불가
it('U023-1: 검수 승인 완료 시 RELEASE_REVIEW로 전환 성공', () => {
const result = releaseEscrow(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.escrowStatus).toBe('RELEASE_REVIEW');
}
});
it('U023-2: 검수 미승인 시 정산 해제 불가', () => {
const result = releaseEscrow(validInput({ hasApprovedInspection: false }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INSPECTION_NOT_APPROVED');
}
});
it('U023-3: HOLDING이 아닌 상태에서는 정산 해제 불가', () => {
const result = releaseEscrow(validInput({ currentEscrowStatus: 'NOT_STARTED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_ESCROW_STATUS');
}
});
it('U023-4: RELEASED 상태에서는 정산 해제 불가', () => {
const result = releaseEscrow(validInput({ currentEscrowStatus: 'RELEASED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_ESCROW_STATUS');
}
});
it('U023-5: 열린 분쟁이 있으면 정산 해제 차단', () => {
const result = releaseEscrow(validInput({ hasOpenDispute: true }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('DISPUTE_OPEN');
}
});
});
@@ -0,0 +1,34 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface CheckIdempotencyInput {
readonly idempotencyKey: string;
readonly alreadyProcessed: boolean;
}
export interface IdempotencyCheckResult {
readonly isNew: boolean;
readonly idempotencyKey: string;
}
// U024: 같은 웹훅 이벤트는 IdempotencyKey 기준으로 한 번만 처리
export function checkIdempotency(
input: CheckIdempotencyInput,
): Result<IdempotencyCheckResult, AppError> {
if (!input.idempotencyKey.trim()) {
return failure(
appError('VALIDATION_ERROR', 'IdempotencyKey는 필수입니다.', { field: 'idempotencyKey' }),
);
}
if (input.alreadyProcessed) {
return success({
isNew: false,
idempotencyKey: input.idempotencyKey,
});
}
return success({
isNew: true,
idempotencyKey: input.idempotencyKey,
});
}
@@ -0,0 +1,51 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export type ContractType = 'ACQUISITION' | 'DEMOLITION' | 'INTERIOR';
export interface CreateContractInput {
readonly matchRequestStatus: string;
readonly contractType: ContractType;
readonly templateCode: string;
readonly policyVersionId: string;
}
export interface ContractDraft {
readonly contractType: ContractType;
readonly status: 'DRAFT';
readonly templateCode: string;
readonly policyVersionId: string;
}
export function createContract(
input: CreateContractInput,
): Result<ContractDraft, AppError> {
// U021: 수락된 매칭만 계약 생성 가능
if (input.matchRequestStatus !== 'ACCEPTED') {
return failure(
appError('MATCH_NOT_ACCEPTED', '수락된 매칭 요청만 계약을 생성할 수 있습니다.', {
matchRequestStatus: input.matchRequestStatus,
}),
);
}
// U022: 템플릿 코드 필수
if (!input.templateCode.trim()) {
return failure(
appError('VALIDATION_ERROR', '계약 템플릿 코드는 필수입니다.', { field: 'templateCode' }),
);
}
// U022: 정책 버전 필수
if (!input.policyVersionId.trim()) {
return failure(
appError('VALIDATION_ERROR', '정책 버전은 필수입니다.', { field: 'policyVersionId' }),
);
}
return success({
contractType: input.contractType,
status: 'DRAFT' as const,
templateCode: input.templateCode,
policyVersionId: input.policyVersionId,
});
}
+24
View File
@@ -0,0 +1,24 @@
export {
createContract,
type ContractType,
type CreateContractInput,
type ContractDraft,
} from './create-contract.js';
export {
releaseEscrow,
type ReleaseEscrowInput,
type ReleaseEscrowResult,
} from './release-escrow.js';
export {
checkIdempotency,
type CheckIdempotencyInput,
type IdempotencyCheckResult,
} from './check-idempotency.js';
export {
openDispute,
type OpenDisputeInput,
type OpenDisputeResult,
} from './open-dispute.js';
@@ -0,0 +1,50 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface OpenDisputeInput {
readonly currentEscrowStatus: string;
readonly contractStatus: string;
readonly reasonCode: string;
readonly description?: string;
}
export interface OpenDisputeResult {
readonly escrowStatus: 'DISPUTED';
readonly reasonCode: string;
readonly description?: string;
}
const DISPUTABLE_CONTRACT_STATUSES = new Set(['ACTIVE', 'SIGNED']);
const DISPUTABLE_ESCROW_STATUSES = new Set(['HOLDING', 'RELEASE_REVIEW']);
// U025: 분쟁이 열리면 에스크로는 DISPUTED로 전환
export function openDispute(
input: OpenDisputeInput,
): Result<OpenDisputeResult, AppError> {
if (!DISPUTABLE_CONTRACT_STATUSES.has(input.contractStatus)) {
return failure(
appError('INVALID_CONTRACT_STATUS', '활성 상태의 계약만 분쟁을 열 수 있습니다.', {
contractStatus: input.contractStatus,
}),
);
}
if (!DISPUTABLE_ESCROW_STATUSES.has(input.currentEscrowStatus)) {
return failure(
appError('INVALID_ESCROW_STATUS', 'HOLDING 또는 RELEASE_REVIEW 상태에서만 분쟁을 열 수 있습니다.', {
currentEscrowStatus: input.currentEscrowStatus,
}),
);
}
if (!input.reasonCode.trim()) {
return failure(
appError('VALIDATION_ERROR', '분쟁 사유 코드는 필수입니다.', { field: 'reasonCode' }),
);
}
return success({
escrowStatus: 'DISPUTED' as const,
reasonCode: input.reasonCode,
description: input.description,
});
}
@@ -0,0 +1,42 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface ReleaseEscrowInput {
readonly currentEscrowStatus: string;
readonly hasApprovedInspection: boolean;
readonly hasOpenDispute: boolean;
}
export interface ReleaseEscrowResult {
readonly escrowStatus: 'RELEASE_REVIEW';
}
export function releaseEscrow(
input: ReleaseEscrowInput,
): Result<ReleaseEscrowResult, AppError> {
// U023: HOLDING 상태에서만 정산 해제 가능
if (input.currentEscrowStatus !== 'HOLDING') {
return failure(
appError('INVALID_ESCROW_STATUS', 'HOLDING 상태에서만 정산 해제를 요청할 수 있습니다.', {
currentEscrowStatus: input.currentEscrowStatus,
}),
);
}
// U023: 검수 승인이 완료되어야 정산 해제 가능
if (!input.hasApprovedInspection) {
return failure(
appError('INSPECTION_NOT_APPROVED', '검수 승인이 완료되어야 정산 해제를 요청할 수 있습니다.'),
);
}
// U026: 분쟁이 열려있으면 정산 해제 차단
if (input.hasOpenDispute) {
return failure(
appError('DISPUTE_OPEN', '열린 분쟁이 있어 정산 해제가 차단됩니다.'),
);
}
return success({
escrowStatus: 'RELEASE_REVIEW' as const,
});
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Base entity interface. All domain entities extend this.
*/
export interface Entity<TId = string> {
readonly id: TId;
readonly createdAt: Date;
readonly updatedAt: Date;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* @relink/domain - Pure TypeScript domain layer
*
* Contains entities, value objects, and business rules.
* No external dependencies allowed.
*/
export type { Entity } from './entity.js';
export type { ValueObject } from './value-object.js';
// Store domain
export {
StoreLease,
StoreFacility,
createStoreDraft,
reviewStore,
publishStore,
filterStoreForViewer,
} from './store/index.js';
export type {
StoreLeaseInput,
StoreLeaseProps,
StoreFacilityInput,
StoreFacilityProps,
CreateStoreDraftInput,
StoreDraft,
RegionChecker,
IndustryChecker,
ReviewStoreInput,
ReviewStoreResult,
ReviewDecision,
ReviewableStatus,
PublishStoreInput,
PublishStoreResult,
StoreData,
ViewerContext,
FilteredStoreData,
} from './store/index.js';
// Matching domain
export { createMatchRequest, acceptMatchRequest, buildSearchDefaults } from './matching/index.js';
export type {
MatchType,
MatchSourceType,
CreateMatchRequestInput,
MatchRequestDraft,
MatchRequestStatus,
AcceptMatchRequestInput,
AcceptMatchRequestResult,
StoreSearchCriteria,
StoreSearchDefaults,
} from './matching/index.js';
// Subsidy domain
export { createSubsidyCase, advanceSubsidyToReady, reviewSubsidyCase } from './subsidy/index.js';
export type {
SubsidyCaseStatus,
ChecklistItemTemplate,
CreateSubsidyCaseInput,
SubsidyCaseDraft,
ChecklistItemStatus,
ChecklistItemState,
AdvanceToReadyInput,
AdvanceToReadyResult,
SubsidyReviewDecision,
ReviewSubsidyCaseInput,
ReviewSubsidyCaseResult,
} from './subsidy/index.js';
// Vendor domain
export {
applyVendorCertification,
approveVendorCertification,
isVendorSearchable,
filterVendorsForSearch,
} from './vendor/index.js';
export type {
VendorCertificationStatus,
VendorType,
ApplyVendorCertificationInput,
VendorCertificationApplication,
VendorCertificationDecision,
ApproveVendorCertificationInput,
ApproveVendorCertificationResult,
VendorSearchEntry,
} from './vendor/index.js';
// Contract domain
export { createContract, releaseEscrow, checkIdempotency, openDispute } from './contract/index.js';
export type {
ContractType,
CreateContractInput,
ContractDraft,
ReleaseEscrowInput,
ReleaseEscrowResult,
CheckIdempotencyInput,
IdempotencyCheckResult,
OpenDisputeInput,
OpenDisputeResult,
} from './contract/index.js';
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { acceptMatchRequest, type AcceptMatchRequestInput, type MatchRequestStatus } from '../accept-match-request.js';
function validInput(overrides: Partial<AcceptMatchRequestInput> = {}): AcceptMatchRequestInput {
return {
currentStatus: 'OPEN',
actorUserId: 'operator-1',
...overrides,
};
}
describe('acceptMatchRequest', () => {
// U013: 매칭 요청 수락
it('U013-1: OPEN 상태에서 수락 가능', () => {
const result = acceptMatchRequest(validInput({ currentStatus: 'OPEN' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('ACCEPTED');
}
});
it('U013-2: REVIEWING 상태에서 수락 가능', () => {
const result = acceptMatchRequest(validInput({ currentStatus: 'REVIEWING' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('ACCEPTED');
}
});
// U013: 수락 불가능한 상태들
const nonAcceptableStatuses: MatchRequestStatus[] = [
'ACCEPTED',
'REJECTED',
'CONTRACTING',
'EXPIRED',
'CANCELLED',
'COMPLETED',
];
nonAcceptableStatuses.forEach((status) => {
it(`U013-3: ${status} 상태에서는 수락 불가`, () => {
const result = acceptMatchRequest(validInput({ currentStatus: status }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
expect(result.error.details?.currentStatus).toBe(status);
}
});
});
});
@@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest';
import { createMatchRequest, type CreateMatchRequestInput } from '../create-match-request.js';
function validInput(overrides: Partial<CreateMatchRequestInput> = {}): CreateMatchRequestInput {
return {
storePublicationStatus: 'PUBLISHED',
storeDealStatus: 'OPEN',
matchType: 'ACQUISITION',
sourceType: 'USER_REQUEST',
requesterUserId: 'user-1',
message: '매장 인수 희망합니다.',
hasOpenRequestBySameUser: false,
...overrides,
};
}
describe('createMatchRequest', () => {
// U010: 매칭 요청 생성 성공
it('U010-1: PUBLISHED + OPEN 매장에 매칭 요청 생성 성공', () => {
const result = createMatchRequest(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.matchType).toBe('ACQUISITION');
expect(result.value.sourceType).toBe('USER_REQUEST');
expect(result.value.status).toBe('OPEN');
expect(result.value.message).toBe('매장 인수 희망합니다.');
}
});
it('U010-2: PUBLISHED + MATCHING 매장에도 매칭 요청 가능', () => {
const result = createMatchRequest(validInput({ storeDealStatus: 'MATCHING' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('OPEN');
}
});
it('U010-3: DEMOLITION 타입 매칭 요청 생성 성공', () => {
const result = createMatchRequest(validInput({ matchType: 'DEMOLITION' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.matchType).toBe('DEMOLITION');
}
});
it('U010-4: INTERIOR 타입 매칭 요청 생성 성공', () => {
const result = createMatchRequest(validInput({ matchType: 'INTERIOR' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.matchType).toBe('INTERIOR');
}
});
it('U010-5: message 없이도 매칭 요청 생성 가능', () => {
const result = createMatchRequest(validInput({ message: undefined }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.message).toBeUndefined();
}
});
// U010: 실패 케이스 - 비공개 매장
it('U010-6: 비공개(DRAFT) 매장은 매칭 요청 불가', () => {
const result = createMatchRequest(validInput({ storePublicationStatus: 'DRAFT' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('STORE_NOT_PUBLISHED');
}
});
it('U010-7: UNPUBLISHED 매장은 매칭 요청 불가', () => {
const result = createMatchRequest(validInput({ storePublicationStatus: 'UNPUBLISHED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('STORE_NOT_PUBLISHED');
}
});
// U010: 실패 케이스 - 매칭 불가능 거래 상태
it('U010-8: CLOSED 거래 상태 매장은 매칭 요청 불가', () => {
const result = createMatchRequest(validInput({ storeDealStatus: 'CLOSED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_DEAL_STATUS');
}
});
it('U010-9: CONTRACTED 거래 상태 매장은 매칭 요청 불가', () => {
const result = createMatchRequest(validInput({ storeDealStatus: 'CONTRACTED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_DEAL_STATUS');
}
});
// U011: 동일 사용자 중복 열린 매칭 요청 방지
it('U011: 동일 사용자가 이미 열린 요청이 있으면 중복 요청 불가', () => {
const result = createMatchRequest(validInput({ hasOpenRequestBySameUser: true }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('DUPLICATE_OPEN_REQUEST');
}
});
// U012: 운영자 수동 추천 시 추천 사유 필수
it('U012-1: 운영자 추천 시 추천 사유 없으면 실패', () => {
const result = createMatchRequest(validInput({
sourceType: 'OPERATOR_RECOMMENDATION',
operatorRecommendationReason: undefined,
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U012-2: 운영자 추천 시 빈 문자열 사유도 실패', () => {
const result = createMatchRequest(validInput({
sourceType: 'OPERATOR_RECOMMENDATION',
operatorRecommendationReason: ' ',
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('U012-3: 운영자 추천 시 유효한 추천 사유가 있으면 성공', () => {
const result = createMatchRequest(validInput({
sourceType: 'OPERATOR_RECOMMENDATION',
operatorRecommendationReason: '해당 매장과 창업 희망 업종이 일치합니다.',
}));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.sourceType).toBe('OPERATOR_RECOMMENDATION');
}
});
// U012: SYSTEM_MATCH는 추천 사유 불필요
it('U012-4: SYSTEM_MATCH는 추천 사유 없어도 성공', () => {
const result = createMatchRequest(validInput({
sourceType: 'SYSTEM_MATCH',
operatorRecommendationReason: undefined,
}));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.sourceType).toBe('SYSTEM_MATCH');
}
});
});
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { buildSearchDefaults, type StoreSearchCriteria } from '../search-stores.js';
describe('buildSearchDefaults', () => {
// U010: 매장 검색 기본값 설정
it('U010-S1: publicationStatus는 항상 PUBLISHED로 고정', () => {
const result = buildSearchDefaults({});
expect(result.publicationStatus).toBe('PUBLISHED');
});
it('U010-S2: page 미지정 시 기본값 1', () => {
const result = buildSearchDefaults({});
expect(result.page).toBe(1);
});
it('U010-S3: limit 미지정 시 기본값 20', () => {
const result = buildSearchDefaults({});
expect(result.limit).toBe(20);
});
it('U010-S4: limit이 100 초과하면 100으로 제한', () => {
const result = buildSearchDefaults({ limit: 500 });
expect(result.limit).toBe(100);
});
it('U010-S5: limit이 100 이하면 그대로 사용', () => {
const result = buildSearchDefaults({ limit: 50 });
expect(result.limit).toBe(50);
});
it('U010-S6: 사용자 지정 page와 limit 유지', () => {
const result = buildSearchDefaults({ page: 3, limit: 30 });
expect(result.page).toBe(3);
expect(result.limit).toBe(30);
});
it('U010-S7: 검색 조건(regionClusterCode, industryLeafCode 등) 유지', () => {
const criteria: StoreSearchCriteria = {
regionClusterCode: 'GANGNAM',
industryLeafCode: 'CAFE',
dealStatus: 'OPEN',
minDepositAmount: 1000,
maxDepositAmount: 5000,
page: 2,
limit: 15,
};
const result = buildSearchDefaults(criteria);
expect(result.regionClusterCode).toBe('GANGNAM');
expect(result.industryLeafCode).toBe('CAFE');
expect(result.dealStatus).toBe('OPEN');
expect(result.minDepositAmount).toBe(1000);
expect(result.maxDepositAmount).toBe(5000);
expect(result.page).toBe(2);
expect(result.limit).toBe(15);
expect(result.publicationStatus).toBe('PUBLISHED');
});
});
@@ -0,0 +1,38 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export type MatchRequestStatus =
| 'OPEN'
| 'REVIEWING'
| 'ACCEPTED'
| 'REJECTED'
| 'CONTRACTING'
| 'EXPIRED'
| 'CANCELLED'
| 'COMPLETED';
export interface AcceptMatchRequestInput {
readonly currentStatus: MatchRequestStatus;
readonly actorUserId: string;
}
export interface AcceptMatchRequestResult {
readonly status: 'ACCEPTED';
}
export function acceptMatchRequest(
input: AcceptMatchRequestInput,
): Result<AcceptMatchRequestResult, AppError> {
const acceptableStatuses: MatchRequestStatus[] = ['OPEN', 'REVIEWING'];
if (!acceptableStatuses.includes(input.currentStatus)) {
return failure(
appError('INVALID_STATUS_TRANSITION', 'OPEN 또는 REVIEWING 상태의 요청만 수락할 수 있습니다.', {
currentStatus: input.currentStatus,
}),
);
}
return success({
status: 'ACCEPTED' as const,
});
}
@@ -0,0 +1,69 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export type MatchType = 'ACQUISITION' | 'DEMOLITION' | 'INTERIOR';
export type MatchSourceType = 'USER_REQUEST' | 'OPERATOR_RECOMMENDATION' | 'SYSTEM_MATCH';
export interface CreateMatchRequestInput {
readonly storePublicationStatus: string;
readonly storeDealStatus: string;
readonly matchType: MatchType;
readonly sourceType: MatchSourceType;
readonly requesterUserId?: string;
readonly message?: string;
readonly hasOpenRequestBySameUser: boolean;
readonly operatorRecommendationReason?: string;
}
export interface MatchRequestDraft {
readonly matchType: MatchType;
readonly sourceType: MatchSourceType;
readonly status: 'OPEN';
readonly message?: string;
}
export function createMatchRequest(
input: CreateMatchRequestInput,
): Result<MatchRequestDraft, AppError> {
// 공개된 매장만 매칭 요청 가능
if (input.storePublicationStatus !== 'PUBLISHED') {
return failure(
appError('STORE_NOT_PUBLISHED', '공개된 매장만 매칭 요청이 가능합니다.', {
publicationStatus: input.storePublicationStatus,
}),
);
}
// OPEN 또는 MATCHING 상태만 매칭 요청 가능
if (input.storeDealStatus !== 'OPEN' && input.storeDealStatus !== 'MATCHING') {
return failure(
appError('INVALID_DEAL_STATUS', '매칭 가능한 상태의 매장이 아닙니다.', {
dealStatus: input.storeDealStatus,
}),
);
}
// U011: 동일 사용자 중복 열린 매칭 요청 방지
if (input.hasOpenRequestBySameUser) {
return failure(
appError('DUPLICATE_OPEN_REQUEST', '이미 해당 매장에 열린 매칭 요청이 있습니다.'),
);
}
// U012: 운영자 수동 추천 시 추천 사유 필수
if (input.sourceType === 'OPERATOR_RECOMMENDATION') {
if (!input.operatorRecommendationReason?.trim()) {
return failure(
appError('VALIDATION_ERROR', '운영자 수동 추천 시 추천 사유는 필수입니다.', {
field: 'operatorRecommendationReason',
}),
);
}
}
return success({
matchType: input.matchType,
sourceType: input.sourceType,
status: 'OPEN' as const,
message: input.message,
});
}
+20
View File
@@ -0,0 +1,20 @@
export {
createMatchRequest,
type MatchType,
type MatchSourceType,
type CreateMatchRequestInput,
type MatchRequestDraft,
} from './create-match-request.js';
export {
acceptMatchRequest,
type MatchRequestStatus,
type AcceptMatchRequestInput,
type AcceptMatchRequestResult,
} from './accept-match-request.js';
export {
buildSearchDefaults,
type StoreSearchCriteria,
type StoreSearchDefaults,
} from './search-stores.js';
@@ -0,0 +1,24 @@
export interface StoreSearchCriteria {
readonly regionClusterCode?: string;
readonly industryLeafCode?: string;
readonly dealStatus?: string;
readonly minDepositAmount?: number;
readonly maxDepositAmount?: number;
readonly page?: number;
readonly limit?: number;
}
export interface StoreSearchDefaults {
readonly publicationStatus: 'PUBLISHED';
readonly page: number;
readonly limit: number;
}
export function buildSearchDefaults(criteria: StoreSearchCriteria): StoreSearchDefaults & StoreSearchCriteria {
return {
...criteria,
publicationStatus: 'PUBLISHED' as const,
page: criteria.page ?? 1,
limit: Math.min(criteria.limit ?? 20, 100),
};
}
@@ -0,0 +1,347 @@
import { describe, it, expect } from 'vitest';
import {
createStoreDraft,
type CreateStoreDraftInput,
type RegionChecker,
type IndustryChecker,
} from '../create-store-draft.js';
import type { StoreLeaseInput } from '../store-lease.js';
import type { StoreFacilityInput } from '../store-facility.js';
// ---------------------------------------------------------------------------
// In-memory implementations (DIP, not mocking)
// ---------------------------------------------------------------------------
function createBetaRegionChecker(betaEnabledCodes: ReadonlySet<string>): RegionChecker {
return {
isBetaEnabled(regionClusterCode: string): boolean {
return betaEnabledCodes.has(regionClusterCode);
},
};
}
function createBetaIndustryChecker(
entries: ReadonlyMap<string, { readonly isLeaf: boolean; readonly isBetaEnabled: boolean }>,
): IndustryChecker {
return {
isSupported(industryLeafCode: string): { exists: boolean; isLeaf: boolean; isBetaEnabled: boolean } {
const entry = entries.get(industryLeafCode);
if (!entry) {
return { exists: false, isLeaf: false, isBetaEnabled: false };
}
return { exists: true, isLeaf: entry.isLeaf, isBetaEnabled: entry.isBetaEnabled };
},
};
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const BETA_REGIONS = new Set(['KR.BETA.GANGNAM_CORE', 'KR.BETA.MAPO_CORE']);
const INDUSTRY_ENTRIES = new Map<string, { isLeaf: boolean; isBetaEnabled: boolean }>([
['FNB.CAFE', { isLeaf: true, isBetaEnabled: true }],
['FNB.KOREAN', { isLeaf: true, isBetaEnabled: true }],
['FNB', { isLeaf: false, isBetaEnabled: true }],
['RETAIL.CLOTHING', { isLeaf: true, isBetaEnabled: false }],
]);
const regionChecker = createBetaRegionChecker(BETA_REGIONS);
const industryChecker = createBetaIndustryChecker(INDUSTRY_ENTRIES);
function validInput(overrides?: Partial<CreateStoreDraftInput>): CreateStoreDraftInput {
return {
ownerUserId: 'user-001',
listingTitle: '역삼동 1층 카페 양도',
industryLeafCode: 'FNB.CAFE',
regionClusterCode: 'KR.BETA.GANGNAM_CORE',
roadAddress: '서울특별시 강남구 테헤란로 123',
...overrides,
};
}
// ---------------------------------------------------------------------------
// U001: 매장 초안 생성 성공
// ---------------------------------------------------------------------------
describe('U001: Create store draft with valid required fields', () => {
it('should create a store draft successfully with valid input', () => {
const result = createStoreDraft(validInput(), regionChecker, industryChecker);
expect(result.ok).toBe(true);
if (result.ok) {
const draft = result.value;
expect(draft.ownerUserId).toBe('user-001');
expect(draft.listingTitle).toBe('역삼동 1층 카페 양도');
expect(draft.industryLeafCode).toBe('FNB.CAFE');
expect(draft.regionClusterCode).toBe('KR.BETA.GANGNAM_CORE');
expect(draft.roadAddress).toBe('서울특별시 강남구 테헤란로 123');
expect(draft.reviewStatus).toBe('DRAFT');
expect(draft.publicationStatus).toBe('PRIVATE');
expect(draft.dealStatus).toBe('OPEN');
}
});
it('should include optional detailAddress when provided', () => {
const result = createStoreDraft(
validInput({ detailAddress: '3층 301호' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.detailAddress).toBe('3층 301호');
}
});
it('should include lease value object when provided', () => {
const lease: StoreLeaseInput = {
depositAmount: 50_000_000,
monthlyRentAmount: 2_000_000,
premiumAmount: 10_000_000,
};
const result = createStoreDraft(validInput({ lease }), regionChecker, industryChecker);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.lease).toBeDefined();
expect(result.value.lease?.depositAmount).toBe(50_000_000);
}
});
it('should include facility value object when provided', () => {
const facility: StoreFacilityInput = {
exclusiveAreaSqm: 66.12,
seatCount: 20,
};
const result = createStoreDraft(validInput({ facility }), regionChecker, industryChecker);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.facility).toBeDefined();
expect(result.value.facility?.exclusiveAreaSqm).toBe(66.12);
}
});
});
// ---------------------------------------------------------------------------
// U002: 베타 대상이 아닌 지역 클러스터
// ---------------------------------------------------------------------------
describe('U002: Region not beta enabled', () => {
it('should fail when region cluster is not beta enabled', () => {
const result = createStoreDraft(
validInput({ regionClusterCode: 'KR.SEOUL.JONGNO' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('REGION_NOT_BETA_ENABLED');
}
});
});
// ---------------------------------------------------------------------------
// U003: 지원하지 않는 업종 코드
// ---------------------------------------------------------------------------
describe('U003: Industry not supported', () => {
it('should fail when industry code is not beta enabled', () => {
const result = createStoreDraft(
validInput({ industryLeafCode: 'RETAIL.CLOTHING' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INDUSTRY_NOT_SUPPORTED');
}
});
it('should fail when industry code is not a leaf node', () => {
const result = createStoreDraft(
validInput({ industryLeafCode: 'FNB' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INDUSTRY_NOT_SUPPORTED');
}
});
it('should fail when industry code does not exist', () => {
const result = createStoreDraft(
validInput({ industryLeafCode: 'UNKNOWN.CODE' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INDUSTRY_NOT_SUPPORTED');
}
});
});
// ---------------------------------------------------------------------------
// U004: Lease / Facility validation propagation
// ---------------------------------------------------------------------------
describe('U004: Lease and facility validation through draft creation', () => {
it('should fail when lease has negative depositAmount', () => {
const result = createStoreDraft(
validInput({
lease: { depositAmount: -1, monthlyRentAmount: 0, premiumAmount: 0 },
}),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('depositAmount');
}
});
it('should fail when facility has zero exclusiveAreaSqm', () => {
const result = createStoreDraft(
validInput({
facility: { exclusiveAreaSqm: 0, seatCount: 10 },
}),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('exclusiveAreaSqm');
}
});
});
// ---------------------------------------------------------------------------
// U005: Required field validation
// ---------------------------------------------------------------------------
describe('U005: Required field validation', () => {
it('should fail when listingTitle is empty', () => {
const result = createStoreDraft(
validInput({ listingTitle: '' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('listingTitle');
}
});
it('should fail when listingTitle is whitespace only', () => {
const result = createStoreDraft(
validInput({ listingTitle: ' ' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('listingTitle');
}
});
it('should fail when industryLeafCode is empty', () => {
const result = createStoreDraft(
validInput({ industryLeafCode: '' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('industryLeafCode');
}
});
it('should fail when regionClusterCode is empty', () => {
const result = createStoreDraft(
validInput({ regionClusterCode: '' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('regionClusterCode');
}
});
it('should fail when roadAddress is empty', () => {
const result = createStoreDraft(
validInput({ roadAddress: '' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('roadAddress');
}
});
it('should fail when ownerUserId is empty', () => {
const result = createStoreDraft(
validInput({ ownerUserId: '' }),
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('ownerUserId');
}
});
it('should return all validation errors when multiple fields are missing', () => {
const result = createStoreDraft(
{
ownerUserId: '',
listingTitle: '',
industryLeafCode: '',
regionClusterCode: '',
roadAddress: '',
},
regionChecker,
industryChecker,
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
const errors = result.error.details?.errors as Array<{ field: string }>;
expect(errors).toBeDefined();
expect(errors.length).toBeGreaterThanOrEqual(5);
const fields = errors.map((e) => e.field);
expect(fields).toContain('ownerUserId');
expect(fields).toContain('listingTitle');
expect(fields).toContain('industryLeafCode');
expect(fields).toContain('regionClusterCode');
expect(fields).toContain('roadAddress');
}
});
});
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import {
filterStoreForViewer,
type StoreData,
type ViewerContext,
} from '../filter-store-for-viewer.js';
const fullStore: StoreData = {
publicId: 'store-1',
listingTitle: '강남역 카페 양도',
publicSummary: '역삼동 카페입니다',
roadAddress: '서울시 강남구 테헤란로 123',
detailAddress: '4층 401호',
ownerUserId: 'user-owner',
ownerPhone: '010-1234-5678',
ownerEmail: 'owner@example.com',
industryName: '카페',
regionName: '강남권 베타 클러스터',
reviewStatus: 'APPROVED',
publicationStatus: 'PUBLISHED',
dealStatus: 'OPEN',
lease: {
depositAmount: 50_000_000,
monthlyRentAmount: 3_000_000,
premiumAmount: 100_000_000,
},
};
describe('U008: filterStoreForViewer', () => {
it('매장 소유자는 모든 정보를 볼 수 있다', () => {
const viewer: ViewerContext = { userId: 'user-owner', role: 'CLOSING_OWNER' };
const filtered = filterStoreForViewer(fullStore, viewer);
expect(filtered.detailAddress).toBe('4층 401호');
expect(filtered.ownerPhone).toBe('010-1234-5678');
expect(filtered.ownerEmail).toBe('owner@example.com');
});
it('운영자는 모든 정보를 볼 수 있다', () => {
const viewer: ViewerContext = { userId: 'operator-1', role: 'OPS_MANAGER' };
const filtered = filterStoreForViewer(fullStore, viewer);
expect(filtered.detailAddress).toBe('4층 401호');
expect(filtered.ownerPhone).toBe('010-1234-5678');
expect(filtered.ownerEmail).toBe('owner@example.com');
});
it('일반 창업자는 상세 주소와 연락처를 볼 수 없다', () => {
const viewer: ViewerContext = { userId: 'founder-1', role: 'FOUNDER' };
const filtered = filterStoreForViewer(fullStore, viewer);
expect(filtered.detailAddress).toBeUndefined();
expect(filtered.ownerPhone).toBeUndefined();
expect(filtered.ownerEmail).toBeUndefined();
// 공개 정보는 그대로
expect(filtered.listingTitle).toBe('강남역 카페 양도');
expect(filtered.roadAddress).toBe('서울시 강남구 테헤란로 123');
expect(filtered.lease).toEqual(fullStore.lease);
});
it('업체 매니저는 상세 주소와 연락처를 볼 수 없다', () => {
const viewer: ViewerContext = { userId: 'vendor-1', role: 'VENDOR_MANAGER' };
const filtered = filterStoreForViewer(fullStore, viewer);
expect(filtered.detailAddress).toBeUndefined();
expect(filtered.ownerPhone).toBeUndefined();
expect(filtered.ownerEmail).toBeUndefined();
});
it('비공개 매장은 소유자와 운영자 외에는 조회 불가', () => {
const privateStore: StoreData = {
...fullStore,
publicationStatus: 'PRIVATE',
};
const viewer: ViewerContext = { userId: 'founder-1', role: 'FOUNDER' };
const result = filterStoreForViewer(privateStore, viewer);
expect(result).toBeNull();
});
it('비공개 매장이라도 소유자는 조회할 수 있다', () => {
const privateStore: StoreData = {
...fullStore,
publicationStatus: 'PRIVATE',
};
const viewer: ViewerContext = { userId: 'user-owner', role: 'CLOSING_OWNER' };
const result = filterStoreForViewer(privateStore, viewer);
expect(result).not.toBeNull();
expect(result!.listingTitle).toBe('강남역 카페 양도');
});
it('비공개 매장이라도 운영자는 조회할 수 있다', () => {
const privateStore: StoreData = {
...fullStore,
publicationStatus: 'PRIVATE',
};
const viewer: ViewerContext = { userId: 'operator-1', role: 'OPS_MANAGER' };
const result = filterStoreForViewer(privateStore, viewer);
expect(result).not.toBeNull();
});
});
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { publishStore, type PublishStoreInput } from '../publish-store.js';
function makeInput(overrides: Partial<PublishStoreInput> = {}): PublishStoreInput {
return {
currentReviewStatus: 'APPROVED',
currentPublicationStatus: 'PRIVATE',
policyVersionId: 'pv-1',
...overrides,
};
}
describe('U006/U009: publishStore', () => {
// U006: 승인된 매장만 공개 가능
it('APPROVED + PRIVATE 상태의 매장을 PUBLISHED로 공개할 수 있다', () => {
const result = publishStore(makeInput());
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.publicationStatus).toBe('PUBLISHED');
expect(result.value.policyVersionId).toBe('pv-1');
});
it('DRAFT 상태의 매장은 공개할 수 없다', () => {
const result = publishStore(makeInput({ currentReviewStatus: 'DRAFT' }));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_APPROVED');
});
it('SUBMITTED 상태의 매장은 공개할 수 없다', () => {
const result = publishStore(makeInput({ currentReviewStatus: 'SUBMITTED' }));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_APPROVED');
});
it('REJECTED 상태의 매장은 공개할 수 없다', () => {
const result = publishStore(makeInput({ currentReviewStatus: 'REJECTED' }));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('NOT_APPROVED');
});
it('이미 PUBLISHED인 매장은 다시 공개할 수 없다', () => {
const result = publishStore(
makeInput({ currentPublicationStatus: 'PUBLISHED' }),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('ALREADY_PUBLISHED');
});
// U009: 정책 버전 필수
it('정책 버전 없이는 공개할 수 없다', () => {
const result = publishStore(makeInput({ policyVersionId: '' }));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('VALIDATION_ERROR');
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { reviewStore, type ReviewStoreInput } from '../review-store.js';
function makeInput(overrides: Partial<ReviewStoreInput> = {}): ReviewStoreInput {
return {
currentReviewStatus: 'SUBMITTED',
decision: 'APPROVED',
reviewerUserId: 'operator-1',
...overrides,
};
}
describe('U006/U007: reviewStore', () => {
// U006: 운영자 승인
it('SUBMITTED 상태의 매장을 APPROVED로 전환할 수 있다', () => {
const result = reviewStore(makeInput({ decision: 'APPROVED' }));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.reviewStatus).toBe('APPROVED');
});
it('SUBMITTED 상태의 매장을 REJECTED로 전환할 수 있다', () => {
const result = reviewStore(
makeInput({
decision: 'REJECTED',
reasonCode: 'INCOMPLETE_INFO',
memo: '시설 정보가 부족합니다.',
}),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.reviewStatus).toBe('REJECTED');
expect(result.value.reasonCode).toBe('INCOMPLETE_INFO');
expect(result.value.memo).toBe('시설 정보가 부족합니다.');
});
// U006: SUBMITTED가 아닌 상태에서는 검토 불가
it('DRAFT 상태의 매장은 검토할 수 없다', () => {
const result = reviewStore(makeInput({ currentReviewStatus: 'DRAFT' }));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
});
it('이미 APPROVED인 매장은 다시 검토할 수 없다', () => {
const result = reviewStore(makeInput({ currentReviewStatus: 'APPROVED' }));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
});
it('REJECTED 상태의 매장은 다시 검토할 수 없다', () => {
const result = reviewStore(makeInput({ currentReviewStatus: 'REJECTED' }));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
});
// U007: 반려 시 사유 코드와 메모 필수
it('반려 시 사유 코드가 없으면 실패한다', () => {
const result = reviewStore(
makeInput({
decision: 'REJECTED',
memo: '정보 부족',
}),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('VALIDATION_ERROR');
});
it('반려 시 메모가 없으면 실패한다', () => {
const result = reviewStore(
makeInput({
decision: 'REJECTED',
reasonCode: 'INCOMPLETE_INFO',
}),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe('VALIDATION_ERROR');
});
it('승인 시에는 사유 코드와 메모가 필수가 아니다', () => {
const result = reviewStore(makeInput({ decision: 'APPROVED' }));
expect(result.ok).toBe(true);
});
});
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { StoreFacility } from '../store-facility.js';
describe('StoreFacility', () => {
it('should create a valid StoreFacility with required fields', () => {
const result = StoreFacility.create({
exclusiveAreaSqm: 66.12,
seatCount: 20,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.exclusiveAreaSqm).toBe(66.12);
expect(result.value.seatCount).toBe(20);
}
});
it('should create a valid StoreFacility with all optional fields', () => {
const result = StoreFacility.create({
exclusiveAreaSqm: 100,
seatCount: 40,
floorLevel: 2,
hasGas: true,
hasDrainage: true,
hasDuct: true,
electricCapacityKw: 30.5,
kitchenEquipmentSummary: 'Industrial oven, dishwasher',
parkingCount: 5,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.floorLevel).toBe(2);
expect(result.value.hasGas).toBe(true);
expect(result.value.hasDrainage).toBe(true);
expect(result.value.hasDuct).toBe(true);
expect(result.value.electricCapacityKw).toBe(30.5);
expect(result.value.kitchenEquipmentSummary).toBe('Industrial oven, dishwasher');
expect(result.value.parkingCount).toBe(5);
}
});
it('should fail when exclusiveAreaSqm is zero', () => {
const result = StoreFacility.create({
exclusiveAreaSqm: 0,
seatCount: 10,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('exclusiveAreaSqm');
}
});
it('should fail when exclusiveAreaSqm is negative', () => {
const result = StoreFacility.create({
exclusiveAreaSqm: -10,
seatCount: 0,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('exclusiveAreaSqm');
}
});
it('should fail when seatCount is negative', () => {
const result = StoreFacility.create({
exclusiveAreaSqm: 50,
seatCount: -1,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('seatCount');
}
});
it('should allow seatCount of zero', () => {
const result = StoreFacility.create({
exclusiveAreaSqm: 33.5,
seatCount: 0,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.seatCount).toBe(0);
}
});
it('should allow optional fields to be undefined', () => {
const result = StoreFacility.create({
exclusiveAreaSqm: 50,
seatCount: 10,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.floorLevel).toBeUndefined();
expect(result.value.hasGas).toBeUndefined();
expect(result.value.hasDrainage).toBeUndefined();
expect(result.value.hasDuct).toBeUndefined();
expect(result.value.electricCapacityKw).toBeUndefined();
expect(result.value.kitchenEquipmentSummary).toBeUndefined();
expect(result.value.parkingCount).toBeUndefined();
}
});
});
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { StoreLease } from '../store-lease.js';
describe('StoreLease', () => {
it('should create a valid StoreLease with all zero amounts', () => {
const result = StoreLease.create({
depositAmount: 0,
monthlyRentAmount: 0,
premiumAmount: 0,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.depositAmount).toBe(0);
expect(result.value.monthlyRentAmount).toBe(0);
expect(result.value.premiumAmount).toBe(0);
}
});
it('should create a valid StoreLease with positive amounts', () => {
const result = StoreLease.create({
depositAmount: 50_000_000,
monthlyRentAmount: 2_000_000,
premiumAmount: 30_000_000,
maintenanceFeeAmount: 200_000,
remainingLeaseMonths: 24,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.depositAmount).toBe(50_000_000);
expect(result.value.monthlyRentAmount).toBe(2_000_000);
expect(result.value.premiumAmount).toBe(30_000_000);
expect(result.value.maintenanceFeeAmount).toBe(200_000);
expect(result.value.remainingLeaseMonths).toBe(24);
}
});
it('should fail when depositAmount is negative', () => {
const result = StoreLease.create({
depositAmount: -1,
monthlyRentAmount: 0,
premiumAmount: 0,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('depositAmount');
}
});
it('should fail when monthlyRentAmount is negative', () => {
const result = StoreLease.create({
depositAmount: 0,
monthlyRentAmount: -100,
premiumAmount: 0,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('monthlyRentAmount');
}
});
it('should fail when premiumAmount is negative', () => {
const result = StoreLease.create({
depositAmount: 0,
monthlyRentAmount: 0,
premiumAmount: -500,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('premiumAmount');
}
});
it('should fail when maintenanceFeeAmount is negative', () => {
const result = StoreLease.create({
depositAmount: 0,
monthlyRentAmount: 0,
premiumAmount: 0,
maintenanceFeeAmount: -10,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('maintenanceFeeAmount');
}
});
it('should allow optional fields to be undefined', () => {
const result = StoreLease.create({
depositAmount: 100_000,
monthlyRentAmount: 50_000,
premiumAmount: 0,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.maintenanceFeeAmount).toBeUndefined();
expect(result.value.remainingLeaseMonths).toBeUndefined();
expect(result.value.leaseExpiresAt).toBeUndefined();
expect(result.value.transferable).toBeUndefined();
}
});
});
@@ -0,0 +1,161 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { StoreLease, type StoreLeaseInput, type StoreLeaseProps } from './store-lease.js';
import { StoreFacility, type StoreFacilityInput, type StoreFacilityProps } from './store-facility.js';
// ---------------------------------------------------------------------------
// Dependency interfaces (DIP)
// ---------------------------------------------------------------------------
export interface RegionChecker {
isBetaEnabled(regionClusterCode: string): boolean;
}
export interface IndustryChecker {
isSupported(industryLeafCode: string): {
readonly exists: boolean;
readonly isLeaf: boolean;
readonly isBetaEnabled: boolean;
};
}
// ---------------------------------------------------------------------------
// Input / Output types
// ---------------------------------------------------------------------------
export interface CreateStoreDraftInput {
readonly ownerUserId: string;
readonly listingTitle: string;
readonly industryLeafCode: string;
readonly regionClusterCode: string;
readonly roadAddress: string;
readonly detailAddress?: string;
readonly lease?: StoreLeaseInput;
readonly facility?: StoreFacilityInput;
}
export interface StoreDraft {
readonly ownerUserId: string;
readonly listingTitle: string;
readonly industryLeafCode: string;
readonly regionClusterCode: string;
readonly roadAddress: string;
readonly detailAddress?: string;
readonly reviewStatus: 'DRAFT';
readonly publicationStatus: 'PRIVATE';
readonly dealStatus: 'OPEN';
readonly lease?: StoreLeaseProps;
readonly facility?: StoreFacilityProps;
}
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
interface FieldError {
readonly field: string;
readonly message: string;
}
function validateRequiredFields(input: CreateStoreDraftInput): readonly FieldError[] {
const errors: FieldError[] = [];
if (!input.ownerUserId.trim()) {
errors.push({ field: 'ownerUserId', message: '소유자 ID는 필수입니다.' });
}
if (!input.listingTitle.trim()) {
errors.push({ field: 'listingTitle', message: '매장 제목은 필수입니다.' });
}
if (!input.industryLeafCode.trim()) {
errors.push({ field: 'industryLeafCode', message: '업종 코드는 필수입니다.' });
}
if (!input.regionClusterCode.trim()) {
errors.push({ field: 'regionClusterCode', message: '지역 클러스터 코드는 필수입니다.' });
}
if (!input.roadAddress.trim()) {
errors.push({ field: 'roadAddress', message: '도로명 주소는 필수입니다.' });
}
return errors;
}
// ---------------------------------------------------------------------------
// Domain function
// ---------------------------------------------------------------------------
export function createStoreDraft(
input: CreateStoreDraftInput,
regionChecker: RegionChecker,
industryChecker: IndustryChecker,
): Result<StoreDraft, AppError> {
// 1. Required field validation
const fieldErrors = validateRequiredFields(input);
if (fieldErrors.length === 1) {
const err = fieldErrors[0]!;
return failure(
appError('VALIDATION_ERROR', err.message, { field: err.field }),
);
}
if (fieldErrors.length > 1) {
return failure(
appError('VALIDATION_ERROR', '필수 입력값이 누락되었습니다.', {
errors: fieldErrors,
}),
);
}
// 2. Region beta check
if (!regionChecker.isBetaEnabled(input.regionClusterCode)) {
return failure(
appError('REGION_NOT_BETA_ENABLED', '베타 서비스 대상 지역이 아닙니다.', {
regionClusterCode: input.regionClusterCode,
}),
);
}
// 3. Industry check
const industryResult = industryChecker.isSupported(input.industryLeafCode);
if (!industryResult.exists || !industryResult.isLeaf || !industryResult.isBetaEnabled) {
return failure(
appError('INDUSTRY_NOT_SUPPORTED', '지원하지 않는 업종 코드입니다.', {
industryLeafCode: input.industryLeafCode,
}),
);
}
// 4. Validate optional lease value object
let leaseProps: StoreLeaseProps | undefined;
if (input.lease) {
const leaseResult = StoreLease.create(input.lease);
if (!leaseResult.ok) {
return failure(leaseResult.error);
}
leaseProps = leaseResult.value;
}
// 5. Validate optional facility value object
let facilityProps: StoreFacilityProps | undefined;
if (input.facility) {
const facilityResult = StoreFacility.create(input.facility);
if (!facilityResult.ok) {
return failure(facilityResult.error);
}
facilityProps = facilityResult.value;
}
// 6. Build the draft
return success({
ownerUserId: input.ownerUserId,
listingTitle: input.listingTitle,
industryLeafCode: input.industryLeafCode,
regionClusterCode: input.regionClusterCode,
roadAddress: input.roadAddress,
detailAddress: input.detailAddress,
reviewStatus: 'DRAFT' as const,
publicationStatus: 'PRIVATE' as const,
dealStatus: 'OPEN' as const,
lease: leaseProps,
facility: facilityProps,
});
}
@@ -0,0 +1,68 @@
const OPERATOR_ROLES = new Set([
'OPS_MANAGER',
'SUPER_ADMIN',
'SUBSIDY_OPERATOR',
'TRUST_OPERATOR',
'FINANCE_OPERATOR',
]);
export interface StoreData {
readonly publicId: string;
readonly listingTitle: string;
readonly publicSummary?: string;
readonly roadAddress: string;
readonly detailAddress?: string;
readonly ownerUserId: string;
readonly ownerPhone?: string;
readonly ownerEmail?: string;
readonly industryName?: string;
readonly regionName?: string;
readonly reviewStatus: string;
readonly publicationStatus: string;
readonly dealStatus: string;
readonly lease?: {
readonly depositAmount: number;
readonly monthlyRentAmount: number;
readonly premiumAmount: number;
};
}
export interface ViewerContext {
readonly userId: string;
readonly role: string;
}
export type FilteredStoreData = Omit<StoreData, 'detailAddress' | 'ownerPhone' | 'ownerEmail'> & {
readonly detailAddress?: string;
readonly ownerPhone?: string;
readonly ownerEmail?: string;
};
function isOwner(store: StoreData, viewer: ViewerContext): boolean {
return store.ownerUserId === viewer.userId;
}
function isOperator(viewer: ViewerContext): boolean {
return OPERATOR_ROLES.has(viewer.role);
}
export function filterStoreForViewer(
store: StoreData,
viewer: ViewerContext,
): FilteredStoreData | null {
const hasPrivilegedAccess = isOwner(store, viewer) || isOperator(viewer);
// 비공개 매장은 소유자/운영자만 조회 가능
if (store.publicationStatus !== 'PUBLISHED' && !hasPrivilegedAccess) {
return null;
}
// 소유자/운영자는 모든 정보 접근 가능
if (hasPrivilegedAccess) {
return { ...store };
}
// 일반 사용자: 민감 정보 제거
const { detailAddress: _, ownerPhone: __, ownerEmail: ___, ...publicData } = store;
return publicData;
}
+27
View File
@@ -0,0 +1,27 @@
export { StoreLease, type StoreLeaseInput, type StoreLeaseProps } from './store-lease.js';
export {
StoreFacility,
type StoreFacilityInput,
type StoreFacilityProps,
} from './store-facility.js';
export {
createStoreDraft,
type CreateStoreDraftInput,
type StoreDraft,
type RegionChecker,
type IndustryChecker,
} from './create-store-draft.js';
export {
reviewStore,
type ReviewStoreInput,
type ReviewStoreResult,
type ReviewDecision,
type ReviewableStatus,
} from './review-store.js';
export { publishStore, type PublishStoreInput, type PublishStoreResult } from './publish-store.js';
export {
filterStoreForViewer,
type StoreData,
type ViewerContext,
type FilteredStoreData,
} from './filter-store-for-viewer.js';
@@ -0,0 +1,43 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface PublishStoreInput {
readonly currentReviewStatus: string;
readonly currentPublicationStatus: string;
readonly policyVersionId: string;
}
export interface PublishStoreResult {
readonly publicationStatus: 'PUBLISHED';
readonly policyVersionId: string;
}
export function publishStore(
input: PublishStoreInput,
): Result<PublishStoreResult, AppError> {
if (input.currentReviewStatus !== 'APPROVED') {
return failure(
appError('NOT_APPROVED', '승인된 매장만 공개할 수 있습니다.', {
currentReviewStatus: input.currentReviewStatus,
}),
);
}
if (input.currentPublicationStatus === 'PUBLISHED') {
return failure(
appError('ALREADY_PUBLISHED', '이미 공개된 매장입니다.', {
currentPublicationStatus: input.currentPublicationStatus,
}),
);
}
if (!input.policyVersionId.trim()) {
return failure(
appError('VALIDATION_ERROR', '공개 시 정책 버전은 필수입니다.', { field: 'policyVersionId' }),
);
}
return success({
publicationStatus: 'PUBLISHED' as const,
policyVersionId: input.policyVersionId,
});
}
+49
View File
@@ -0,0 +1,49 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export type ReviewDecision = 'APPROVED' | 'REJECTED';
export type ReviewableStatus = 'DRAFT' | 'SUBMITTED' | 'REVIEWING' | 'APPROVED' | 'REJECTED';
export interface ReviewStoreInput {
readonly currentReviewStatus: ReviewableStatus;
readonly decision: ReviewDecision;
readonly reviewerUserId: string;
readonly reasonCode?: string;
readonly memo?: string;
}
export interface ReviewStoreResult {
readonly reviewStatus: 'APPROVED' | 'REJECTED';
readonly reasonCode?: string;
readonly memo?: string;
}
export function reviewStore(
input: ReviewStoreInput,
): Result<ReviewStoreResult, AppError> {
if (input.currentReviewStatus !== 'SUBMITTED') {
return failure(
appError('INVALID_STATUS_TRANSITION', 'SUBMITTED 상태의 매장만 검토할 수 있습니다.', {
currentStatus: input.currentReviewStatus,
}),
);
}
if (input.decision === 'REJECTED') {
if (!input.reasonCode?.trim()) {
return failure(
appError('VALIDATION_ERROR', '반려 시 사유 코드는 필수입니다.', { field: 'reasonCode' }),
);
}
if (!input.memo?.trim()) {
return failure(
appError('VALIDATION_ERROR', '반려 시 메모는 필수입니다.', { field: 'memo' }),
);
}
}
return success({
reviewStatus: input.decision,
reasonCode: input.reasonCode,
memo: input.memo,
});
}
@@ -0,0 +1,53 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface StoreFacilityInput {
readonly exclusiveAreaSqm: number;
readonly seatCount: number;
readonly floorLevel?: number;
readonly hasGas?: boolean;
readonly hasDrainage?: boolean;
readonly hasDuct?: boolean;
readonly electricCapacityKw?: number;
readonly kitchenEquipmentSummary?: string;
readonly parkingCount?: number;
}
export interface StoreFacilityProps {
readonly exclusiveAreaSqm: number;
readonly seatCount: number;
readonly floorLevel?: number;
readonly hasGas?: boolean;
readonly hasDrainage?: boolean;
readonly hasDuct?: boolean;
readonly electricCapacityKw?: number;
readonly kitchenEquipmentSummary?: string;
readonly parkingCount?: number;
}
export const StoreFacility = {
create(input: StoreFacilityInput): Result<StoreFacilityProps, AppError> {
if (input.exclusiveAreaSqm <= 0) {
return failure(
appError('VALIDATION_ERROR', '전용면적은 0보다 커야 합니다.', { field: 'exclusiveAreaSqm' }),
);
}
if (input.seatCount < 0) {
return failure(
appError('VALIDATION_ERROR', '좌석 수는 0 이상이어야 합니다.', { field: 'seatCount' }),
);
}
return success({
exclusiveAreaSqm: input.exclusiveAreaSqm,
seatCount: input.seatCount,
floorLevel: input.floorLevel,
hasGas: input.hasGas,
hasDrainage: input.hasDrainage,
hasDuct: input.hasDuct,
electricCapacityKw: input.electricCapacityKw,
kitchenEquipmentSummary: input.kitchenEquipmentSummary,
parkingCount: input.parkingCount,
});
},
} as const;
+59
View File
@@ -0,0 +1,59 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export interface StoreLeaseInput {
readonly depositAmount: number;
readonly monthlyRentAmount: number;
readonly premiumAmount: number;
readonly maintenanceFeeAmount?: number;
readonly remainingLeaseMonths?: number;
readonly leaseExpiresAt?: Date;
readonly transferable?: boolean;
}
export interface StoreLeaseProps {
readonly depositAmount: number;
readonly monthlyRentAmount: number;
readonly premiumAmount: number;
readonly maintenanceFeeAmount?: number;
readonly remainingLeaseMonths?: number;
readonly leaseExpiresAt?: Date;
readonly transferable?: boolean;
}
export const StoreLease = {
create(input: StoreLeaseInput): Result<StoreLeaseProps, AppError> {
if (input.depositAmount < 0) {
return failure(
appError('VALIDATION_ERROR', '보증금은 0 이상이어야 합니다.', { field: 'depositAmount' }),
);
}
if (input.monthlyRentAmount < 0) {
return failure(
appError('VALIDATION_ERROR', '월세는 0 이상이어야 합니다.', { field: 'monthlyRentAmount' }),
);
}
if (input.premiumAmount < 0) {
return failure(
appError('VALIDATION_ERROR', '권리금은 0 이상이어야 합니다.', { field: 'premiumAmount' }),
);
}
if (input.maintenanceFeeAmount !== undefined && input.maintenanceFeeAmount < 0) {
return failure(
appError('VALIDATION_ERROR', '관리비는 0 이상이어야 합니다.', { field: 'maintenanceFeeAmount' }),
);
}
return success({
depositAmount: input.depositAmount,
monthlyRentAmount: input.monthlyRentAmount,
premiumAmount: input.premiumAmount,
maintenanceFeeAmount: input.maintenanceFeeAmount,
remainingLeaseMonths: input.remainingLeaseMonths,
leaseExpiresAt: input.leaseExpiresAt,
transferable: input.transferable,
});
},
} as const;
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { advanceSubsidyToReady, type AdvanceToReadyInput } from '../advance-subsidy-to-ready.js';
function validInput(overrides: Partial<AdvanceToReadyInput> = {}): AdvanceToReadyInput {
return {
currentStatus: 'DOCUMENTS_PENDING',
checklistItems: [
{ itemCode: 'BUSINESS_LICENSE', isRequired: true, status: 'CHECKED' },
{ itemCode: 'LEASE_CONTRACT', isRequired: true, status: 'CHECKED' },
{ itemCode: 'PHOTO_EXTERIOR', isRequired: false, status: 'PENDING' },
],
requiredDocumentCount: 2,
uploadedDocumentCount: 2,
...overrides,
};
}
describe('advanceSubsidyToReady', () => {
// U015: 필수 서류 누락 시 READY_TO_SUBMIT 불가
it('U015-1: 모든 필수 항목 완료 + 서류 제출 완료 시 READY_TO_SUBMIT 전환 성공', () => {
const result = advanceSubsidyToReady(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('READY_TO_SUBMIT');
}
});
it('U015-2: 필수 체크리스트 항목이 PENDING이면 전환 불가', () => {
const result = advanceSubsidyToReady(validInput({
checklistItems: [
{ itemCode: 'BUSINESS_LICENSE', isRequired: true, status: 'CHECKED' },
{ itemCode: 'LEASE_CONTRACT', isRequired: true, status: 'PENDING' },
{ itemCode: 'PHOTO_EXTERIOR', isRequired: false, status: 'PENDING' },
],
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('CHECKLIST_INCOMPLETE');
expect(result.error.details?.uncheckedItems).toContain('LEASE_CONTRACT');
}
});
it('U015-3: 선택 항목은 PENDING이어도 전환 가능', () => {
const result = advanceSubsidyToReady(validInput({
checklistItems: [
{ itemCode: 'BUSINESS_LICENSE', isRequired: true, status: 'CHECKED' },
{ itemCode: 'LEASE_CONTRACT', isRequired: true, status: 'CHECKED' },
{ itemCode: 'PHOTO_EXTERIOR', isRequired: false, status: 'PENDING' },
],
}));
expect(result.ok).toBe(true);
});
it('U015-4: NOT_APPLICABLE 상태의 필수 항목은 완료로 간주', () => {
const result = advanceSubsidyToReady(validInput({
checklistItems: [
{ itemCode: 'BUSINESS_LICENSE', isRequired: true, status: 'CHECKED' },
{ itemCode: 'LEASE_CONTRACT', isRequired: true, status: 'NOT_APPLICABLE' },
],
}));
expect(result.ok).toBe(true);
});
it('U015-5: 필수 서류가 부족하면 전환 불가', () => {
const result = advanceSubsidyToReady(validInput({
requiredDocumentCount: 3,
uploadedDocumentCount: 1,
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('DOCUMENTS_INCOMPLETE');
expect(result.error.details?.required).toBe(3);
expect(result.error.details?.uploaded).toBe(1);
}
});
it('U015-6: DOCUMENTS_PENDING가 아닌 상태에서는 전환 불가', () => {
const result = advanceSubsidyToReady(validInput({
currentStatus: 'DRAFT',
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
}
});
it('U015-7: SUBMITTED 상태에서도 전환 불가', () => {
const result = advanceSubsidyToReady(validInput({
currentStatus: 'SUBMITTED',
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
}
});
});
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import { createSubsidyCase, type CreateSubsidyCaseInput } from '../create-subsidy-case.js';
const defaultChecklist = [
{ itemCode: 'BUSINESS_LICENSE', isRequired: true },
{ itemCode: 'LEASE_CONTRACT', isRequired: true },
{ itemCode: 'PHOTO_EXTERIOR', isRequired: false },
];
function validInput(overrides: Partial<CreateSubsidyCaseInput> = {}): CreateSubsidyCaseInput {
return {
storeReviewStatus: 'APPROVED',
storePublicationStatus: 'PUBLISHED',
programCode: 'SMALL_BIZ_CLOSURE_2024',
policyVersionId: 'pv-1',
checklistVersionCode: 'v1.0',
checklistItems: defaultChecklist,
...overrides,
};
}
describe('createSubsidyCase', () => {
// U014: 공개 또는 검토 가능한 매장만 케이스 시작 가능
it('U014-1: PUBLISHED 매장으로 지원금 케이스 생성 성공', () => {
const result = createSubsidyCase(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('DRAFT');
expect(result.value.programCode).toBe('SMALL_BIZ_CLOSURE_2024');
expect(result.value.checklistItems).toHaveLength(3);
}
});
it('U014-2: SUBMITTED(검토 가능) 매장도 케이스 생성 가능', () => {
const result = createSubsidyCase(validInput({
storeReviewStatus: 'SUBMITTED',
storePublicationStatus: 'PRIVATE',
}));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('DRAFT');
}
});
it('U014-3: APPROVED(검토 완료) 매장도 케이스 생성 가능', () => {
const result = createSubsidyCase(validInput({
storeReviewStatus: 'APPROVED',
storePublicationStatus: 'PRIVATE',
}));
expect(result.ok).toBe(true);
});
it('U014-4: DRAFT 매장(비공개+미검토)은 케이스 시작 불가', () => {
const result = createSubsidyCase(validInput({
storeReviewStatus: 'DRAFT',
storePublicationStatus: 'PRIVATE',
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('STORE_NOT_ELIGIBLE');
}
});
it('U014-5: REJECTED 매장은 케이스 시작 불가', () => {
const result = createSubsidyCase(validInput({
storeReviewStatus: 'REJECTED',
storePublicationStatus: 'PRIVATE',
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('STORE_NOT_ELIGIBLE');
}
});
// U017: 정책 버전에 따른 체크리스트 적용
it('U017-1: 정책 버전과 체크리스트 버전이 결과에 포함된다', () => {
const result = createSubsidyCase(validInput({
policyVersionId: 'pv-2',
checklistVersionCode: 'v2.0',
}));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.policyVersionId).toBe('pv-2');
expect(result.value.checklistVersionCode).toBe('v2.0');
}
});
it('U017-2: 빈 체크리스트로는 케이스를 생성할 수 없다', () => {
const result = createSubsidyCase(validInput({
checklistItems: [],
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('EMPTY_CHECKLIST');
}
});
it('U017-3: 다른 프로그램 코드도 정상 생성된다', () => {
const result = createSubsidyCase(validInput({
programCode: 'INTERIOR_SUBSIDY_2024',
}));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.programCode).toBe('INTERIOR_SUBSIDY_2024');
}
});
});
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import { reviewSubsidyCase, type ReviewSubsidyCaseInput } from '../review-subsidy-case.js';
function validApprovalInput(overrides: Partial<ReviewSubsidyCaseInput> = {}): ReviewSubsidyCaseInput {
return {
currentStatus: 'SUBMITTED',
decision: 'APPROVED',
...overrides,
};
}
function validRejectionInput(overrides: Partial<ReviewSubsidyCaseInput> = {}): ReviewSubsidyCaseInput {
return {
currentStatus: 'SUBMITTED',
decision: 'REJECTED',
rejectionReasonCode: 'INCOMPLETE_DOCUMENTS',
memo: '사업자등록증이 만료되었습니다. 갱신본을 다시 제출해 주세요.',
...overrides,
};
}
describe('reviewSubsidyCase', () => {
// U016: 운영자 검토 - 승인
it('U016-1: SUBMITTED 케이스 승인 성공', () => {
const result = reviewSubsidyCase(validApprovalInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('APPROVED');
}
});
it('U016-2: REVIEWING 케이스 승인 성공', () => {
const result = reviewSubsidyCase(validApprovalInput({ currentStatus: 'REVIEWING' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('APPROVED');
}
});
// U016: 운영자 검토 - 반려
it('U016-3: 반려 시 사유 코드와 메모가 결과에 포함된다', () => {
const result = reviewSubsidyCase(validRejectionInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('REJECTED');
expect(result.value.rejectionReasonCode).toBe('INCOMPLETE_DOCUMENTS');
expect(result.value.memo).toBe('사업자등록증이 만료되었습니다. 갱신본을 다시 제출해 주세요.');
}
});
it('U016-4: 반려 시 사유 코드 없으면 실패', () => {
const result = reviewSubsidyCase(validRejectionInput({
rejectionReasonCode: undefined,
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('REJECTION_REASON_REQUIRED');
}
});
it('U016-5: 반려 시 빈 사유 코드도 실패', () => {
const result = reviewSubsidyCase(validRejectionInput({
rejectionReasonCode: ' ',
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('REJECTION_REASON_REQUIRED');
}
});
it('U016-6: 반려 시 메모 없으면 실패', () => {
const result = reviewSubsidyCase(validRejectionInput({
memo: undefined,
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('REJECTION_MEMO_REQUIRED');
}
});
it('U016-7: 반려 시 빈 메모도 실패', () => {
const result = reviewSubsidyCase(validRejectionInput({
memo: ' ',
}));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('REJECTION_MEMO_REQUIRED');
}
});
// 상태 전이 제한
it('U016-8: DRAFT 상태에서는 검토 불가', () => {
const result = reviewSubsidyCase(validApprovalInput({ currentStatus: 'DRAFT' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
}
});
it('U016-9: APPROVED 상태에서는 재검토 불가', () => {
const result = reviewSubsidyCase(validApprovalInput({ currentStatus: 'APPROVED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
}
});
it('U016-10: CLOSED 상태에서는 검토 불가', () => {
const result = reviewSubsidyCase(validApprovalInput({ currentStatus: 'CLOSED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
}
});
});
@@ -0,0 +1,60 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export type ChecklistItemStatus = 'PENDING' | 'CHECKED' | 'NOT_APPLICABLE';
export interface ChecklistItemState {
readonly itemCode: string;
readonly isRequired: boolean;
readonly status: ChecklistItemStatus;
}
export interface AdvanceToReadyInput {
readonly currentStatus: string;
readonly checklistItems: readonly ChecklistItemState[];
readonly requiredDocumentCount: number;
readonly uploadedDocumentCount: number;
}
export interface AdvanceToReadyResult {
readonly status: 'READY_TO_SUBMIT';
}
export function advanceSubsidyToReady(
input: AdvanceToReadyInput,
): Result<AdvanceToReadyResult, AppError> {
// DOCUMENTS_PENDING 상태에서만 READY_TO_SUBMIT으로 전이 가능
if (input.currentStatus !== 'DOCUMENTS_PENDING') {
return failure(
appError('INVALID_STATUS_TRANSITION', 'DOCUMENTS_PENDING 상태에서만 제출 준비로 전환할 수 있습니다.', {
currentStatus: input.currentStatus,
}),
);
}
// U015: 필수 체크리스트 항목이 모두 CHECKED여야 함
const uncheckedRequired = input.checklistItems.filter(
(item) => item.isRequired && item.status === 'PENDING',
);
if (uncheckedRequired.length > 0) {
return failure(
appError('CHECKLIST_INCOMPLETE', '필수 체크리스트 항목이 모두 완료되어야 합니다.', {
uncheckedItems: uncheckedRequired.map((item) => item.itemCode),
}),
);
}
// U015: 필수 서류가 모두 업로드되어야 함
if (input.uploadedDocumentCount < input.requiredDocumentCount) {
return failure(
appError('DOCUMENTS_INCOMPLETE', '필수 서류가 모두 업로드되어야 합니다.', {
required: input.requiredDocumentCount,
uploaded: input.uploadedDocumentCount,
}),
);
}
return success({
status: 'READY_TO_SUBMIT' as const,
});
}
@@ -0,0 +1,69 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export type SubsidyCaseStatus =
| 'DRAFT'
| 'ELIGIBILITY_CHECKED'
| 'DOCUMENTS_PENDING'
| 'REVIEWING'
| 'READY_TO_SUBMIT'
| 'SUBMITTED'
| 'APPROVED'
| 'REJECTED'
| 'CLOSED';
export interface ChecklistItemTemplate {
readonly itemCode: string;
readonly isRequired: boolean;
}
export interface CreateSubsidyCaseInput {
readonly storeReviewStatus: string;
readonly storePublicationStatus: string;
readonly programCode: string;
readonly policyVersionId: string;
readonly checklistVersionCode: string;
readonly checklistItems: readonly ChecklistItemTemplate[];
}
export interface SubsidyCaseDraft {
readonly programCode: string;
readonly status: 'DRAFT';
readonly policyVersionId: string;
readonly checklistVersionCode: string;
readonly checklistItems: readonly ChecklistItemTemplate[];
}
const ELIGIBLE_REVIEW_STATUSES = new Set(['SUBMITTED', 'APPROVED']);
const ELIGIBLE_PUBLICATION_STATUSES = new Set(['PUBLISHED']);
export function createSubsidyCase(
input: CreateSubsidyCaseInput,
): Result<SubsidyCaseDraft, AppError> {
// U014: 공개 또는 검토 가능한 매장만 지원금 케이스 시작 가능
const isPublished = ELIGIBLE_PUBLICATION_STATUSES.has(input.storePublicationStatus);
const isReviewable = ELIGIBLE_REVIEW_STATUSES.has(input.storeReviewStatus);
if (!isPublished && !isReviewable) {
return failure(
appError('STORE_NOT_ELIGIBLE', '공개되었거나 검토 가능한 매장만 지원금 케이스를 시작할 수 있습니다.', {
reviewStatus: input.storeReviewStatus,
publicationStatus: input.storePublicationStatus,
}),
);
}
// U017: 체크리스트 항목이 없으면 케이스를 생성할 수 없다
if (input.checklistItems.length === 0) {
return failure(
appError('EMPTY_CHECKLIST', '체크리스트 항목이 최소 1개 이상 있어야 합니다.'),
);
}
return success({
programCode: input.programCode,
status: 'DRAFT' as const,
policyVersionId: input.policyVersionId,
checklistVersionCode: input.checklistVersionCode,
checklistItems: input.checklistItems,
});
}
+22
View File
@@ -0,0 +1,22 @@
export {
createSubsidyCase,
type SubsidyCaseStatus,
type ChecklistItemTemplate,
type CreateSubsidyCaseInput,
type SubsidyCaseDraft,
} from './create-subsidy-case.js';
export {
advanceSubsidyToReady,
type ChecklistItemStatus,
type ChecklistItemState,
type AdvanceToReadyInput,
type AdvanceToReadyResult,
} from './advance-subsidy-to-ready.js';
export {
reviewSubsidyCase,
type SubsidyReviewDecision,
type ReviewSubsidyCaseInput,
type ReviewSubsidyCaseResult,
} from './review-subsidy-case.js';
@@ -0,0 +1,55 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export type SubsidyReviewDecision = 'APPROVED' | 'REJECTED';
export interface ReviewSubsidyCaseInput {
readonly currentStatus: string;
readonly decision: SubsidyReviewDecision;
readonly rejectionReasonCode?: string;
readonly memo?: string;
}
export interface ReviewSubsidyCaseResult {
readonly status: 'APPROVED' | 'REJECTED';
readonly rejectionReasonCode?: string;
readonly memo?: string;
}
const REVIEWABLE_STATUSES = new Set(['SUBMITTED', 'REVIEWING']);
export function reviewSubsidyCase(
input: ReviewSubsidyCaseInput,
): Result<ReviewSubsidyCaseResult, AppError> {
if (!REVIEWABLE_STATUSES.has(input.currentStatus)) {
return failure(
appError('INVALID_STATUS_TRANSITION', 'SUBMITTED 또는 REVIEWING 상태의 케이스만 검토할 수 있습니다.', {
currentStatus: input.currentStatus,
}),
);
}
// U016: 반려 시 사유 코드 필수
if (input.decision === 'REJECTED') {
if (!input.rejectionReasonCode?.trim()) {
return failure(
appError('REJECTION_REASON_REQUIRED', '반려 시 반려 사유 코드는 필수입니다.', {
field: 'rejectionReasonCode',
}),
);
}
if (!input.memo?.trim()) {
return failure(
appError('REJECTION_MEMO_REQUIRED', '반려 시 보완 요청 메모는 필수입니다.', {
field: 'memo',
}),
);
}
}
return success({
status: input.decision,
rejectionReasonCode: input.rejectionReasonCode,
memo: input.memo,
});
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Base value object interface. Value objects are compared by value, not identity.
*/
export interface ValueObject<T> {
readonly value: T;
equals(other: ValueObject<T>): boolean;
}
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { applyVendorCertification, type ApplyVendorCertificationInput } from '../apply-vendor-certification.js';
function validInput(overrides: Partial<ApplyVendorCertificationInput> = {}): ApplyVendorCertificationInput {
return {
vendorType: 'DEMOLITION',
businessName: '(주)클린철거',
contactName: '김담당',
hasBusinessRegistration: true,
coverageRegionCount: 2,
...overrides,
};
}
describe('applyVendorCertification', () => {
it('유효한 입력으로 인증 신청 성공', () => {
const result = applyVendorCertification(validInput());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.certificationStatus).toBe('APPLIED');
expect(result.value.vendorType).toBe('DEMOLITION');
expect(result.value.businessName).toBe('(주)클린철거');
}
});
it('INTERIOR 업체도 신청 가능', () => {
const result = applyVendorCertification(validInput({ vendorType: 'INTERIOR' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.vendorType).toBe('INTERIOR');
}
});
it('BOTH 업체도 신청 가능', () => {
const result = applyVendorCertification(validInput({ vendorType: 'BOTH' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.vendorType).toBe('BOTH');
}
});
it('업체명이 비어있으면 실패', () => {
const result = applyVendorCertification(validInput({ businessName: '' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('businessName');
}
});
it('담당자명이 비어있으면 실패', () => {
const result = applyVendorCertification(validInput({ contactName: ' ' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
expect(result.error.details?.field).toBe('contactName');
}
});
it('사업자등록증이 없으면 실패', () => {
const result = applyVendorCertification(validInput({ hasBusinessRegistration: false }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('MISSING_BUSINESS_REGISTRATION');
}
});
it('서비스 권역이 0개면 실패', () => {
const result = applyVendorCertification(validInput({ coverageRegionCount: 0 }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('MISSING_COVERAGE_REGION');
}
});
});
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import { approveVendorCertification, type ApproveVendorCertificationInput } from '../approve-vendor-certification.js';
function validApproval(overrides: Partial<ApproveVendorCertificationInput> = {}): ApproveVendorCertificationInput {
return {
currentStatus: 'APPLIED',
decision: 'APPROVED',
hasDocumentChecklist: true,
coverageRegionCount: 2,
...overrides,
};
}
describe('approveVendorCertification', () => {
// U018: 필수 인증 서류와 서비스 권역이 없으면 승인 불가
it('U018-1: 서류+권역 완비 시 승인 성공', () => {
const result = approveVendorCertification(validApproval());
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('APPROVED');
}
});
it('U018-2: REVIEWING 상태에서도 승인 가능', () => {
const result = approveVendorCertification(validApproval({ currentStatus: 'REVIEWING' }));
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('APPROVED');
}
});
it('U018-3: 인증 서류 미확인 시 승인 불가', () => {
const result = approveVendorCertification(validApproval({ hasDocumentChecklist: false }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('MISSING_DOCUMENTS');
}
});
it('U018-4: 서비스 권역 0개면 승인 불가', () => {
const result = approveVendorCertification(validApproval({ coverageRegionCount: 0 }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('MISSING_COVERAGE_REGION');
}
});
// U020: 상태 변경과 제재 이력
it('U020-1: SUSPENDED 시 사유 코드 필수', () => {
const result = approveVendorCertification({
currentStatus: 'APPLIED',
decision: 'SUSPENDED',
hasDocumentChecklist: true,
coverageRegionCount: 1,
reasonCode: undefined,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('REASON_REQUIRED');
}
});
it('U020-2: SUSPENDED 시 유효한 사유 코드가 있으면 성공', () => {
const result = approveVendorCertification({
currentStatus: 'APPLIED',
decision: 'SUSPENDED',
hasDocumentChecklist: true,
coverageRegionCount: 1,
reasonCode: 'COMPLIANCE_VIOLATION',
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('SUSPENDED');
expect(result.value.reasonCode).toBe('COMPLIANCE_VIOLATION');
}
});
it('U020-3: REJECTED 시 사유 코드 필수', () => {
const result = approveVendorCertification({
currentStatus: 'APPLIED',
decision: 'REJECTED',
hasDocumentChecklist: true,
coverageRegionCount: 1,
reasonCode: undefined,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('REASON_REQUIRED');
}
});
it('U020-4: REJECTED 시 유효한 사유 코드가 있으면 성공', () => {
const result = approveVendorCertification({
currentStatus: 'APPLIED',
decision: 'REJECTED',
hasDocumentChecklist: true,
coverageRegionCount: 1,
reasonCode: 'INCOMPLETE_DOCUMENTS',
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.status).toBe('REJECTED');
}
});
// 상태 전이 제한
it('이미 APPROVED인 인증은 재검토 불가', () => {
const result = approveVendorCertification(validApproval({ currentStatus: 'APPROVED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
}
});
it('SUSPENDED 상태에서는 검토 불가', () => {
const result = approveVendorCertification(validApproval({ currentStatus: 'SUSPENDED' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('INVALID_STATUS_TRANSITION');
}
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { isVendorSearchable, filterVendorsForSearch, type VendorSearchEntry } from '../filter-vendor-for-search.js';
function makeVendor(overrides: Partial<VendorSearchEntry> = {}): VendorSearchEntry {
return {
publicId: 'vendor-1',
businessName: '(주)클린철거',
vendorType: 'DEMOLITION',
certificationStatus: 'APPROVED',
isDeleted: false,
...overrides,
};
}
describe('filter-vendor-for-search', () => {
// U019: 인증 중지된 업체 제외
describe('isVendorSearchable', () => {
it('U019-1: APPROVED 업체는 검색 가능', () => {
expect(isVendorSearchable(makeVendor())).toBe(true);
});
it('U019-2: SUSPENDED 업체는 검색 불가', () => {
expect(isVendorSearchable(makeVendor({ certificationStatus: 'SUSPENDED' }))).toBe(false);
});
it('U019-3: EXPIRED 업체는 검색 불가', () => {
expect(isVendorSearchable(makeVendor({ certificationStatus: 'EXPIRED' }))).toBe(false);
});
it('U019-4: REJECTED 업체는 검색 불가', () => {
expect(isVendorSearchable(makeVendor({ certificationStatus: 'REJECTED' }))).toBe(false);
});
it('U019-5: APPLIED(심사 전) 업체는 검색 불가', () => {
expect(isVendorSearchable(makeVendor({ certificationStatus: 'APPLIED' }))).toBe(false);
});
it('U019-6: REVIEWING(심사 중) 업체는 검색 불가', () => {
expect(isVendorSearchable(makeVendor({ certificationStatus: 'REVIEWING' }))).toBe(false);
});
it('U019-7: 삭제된 업체는 검색 불가', () => {
expect(isVendorSearchable(makeVendor({ isDeleted: true }))).toBe(false);
});
});
describe('filterVendorsForSearch', () => {
it('APPROVED만 남기고 나머지 제외', () => {
const vendors = [
makeVendor({ publicId: 'v1', certificationStatus: 'APPROVED' }),
makeVendor({ publicId: 'v2', certificationStatus: 'SUSPENDED' }),
makeVendor({ publicId: 'v3', certificationStatus: 'APPROVED' }),
makeVendor({ publicId: 'v4', certificationStatus: 'APPLIED' }),
makeVendor({ publicId: 'v5', isDeleted: true }),
];
const result = filterVendorsForSearch(vendors);
expect(result).toHaveLength(2);
expect(result.map((v) => v.publicId)).toEqual(['v1', 'v3']);
});
it('빈 배열이면 빈 배열 반환', () => {
expect(filterVendorsForSearch([])).toEqual([]);
});
});
});
@@ -0,0 +1,61 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
export type VendorCertificationStatus =
| 'APPLIED'
| 'REVIEWING'
| 'APPROVED'
| 'REJECTED'
| 'SUSPENDED'
| 'EXPIRED';
export type VendorType = 'DEMOLITION' | 'INTERIOR' | 'BOTH';
export interface ApplyVendorCertificationInput {
readonly vendorType: VendorType;
readonly businessName: string;
readonly contactName: string;
readonly hasBusinessRegistration: boolean;
readonly coverageRegionCount: number;
}
export interface VendorCertificationApplication {
readonly vendorType: VendorType;
readonly businessName: string;
readonly contactName: string;
readonly certificationStatus: 'APPLIED';
}
export function applyVendorCertification(
input: ApplyVendorCertificationInput,
): Result<VendorCertificationApplication, AppError> {
if (!input.businessName.trim()) {
return failure(
appError('VALIDATION_ERROR', '업체명은 필수입니다.', { field: 'businessName' }),
);
}
if (!input.contactName.trim()) {
return failure(
appError('VALIDATION_ERROR', '담당자명은 필수입니다.', { field: 'contactName' }),
);
}
if (!input.hasBusinessRegistration) {
return failure(
appError('MISSING_BUSINESS_REGISTRATION', '사업자등록증이 필요합니다.'),
);
}
if (input.coverageRegionCount === 0) {
return failure(
appError('MISSING_COVERAGE_REGION', '서비스 권역을 최소 1개 이상 등록해야 합니다.'),
);
}
return success({
vendorType: input.vendorType,
businessName: input.businessName,
contactName: input.contactName,
certificationStatus: 'APPLIED' as const,
});
}
@@ -0,0 +1,67 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import type { VendorCertificationStatus } from './apply-vendor-certification.js';
export type VendorCertificationDecision = 'APPROVED' | 'REJECTED' | 'SUSPENDED';
export interface ApproveVendorCertificationInput {
readonly currentStatus: VendorCertificationStatus;
readonly decision: VendorCertificationDecision;
readonly hasDocumentChecklist: boolean;
readonly coverageRegionCount: number;
readonly reasonCode?: string;
}
export interface ApproveVendorCertificationResult {
readonly status: VendorCertificationStatus;
readonly reasonCode?: string;
}
const REVIEWABLE_STATUSES = new Set<VendorCertificationStatus>(['APPLIED', 'REVIEWING']);
export function approveVendorCertification(
input: ApproveVendorCertificationInput,
): Result<ApproveVendorCertificationResult, AppError> {
if (!REVIEWABLE_STATUSES.has(input.currentStatus)) {
return failure(
appError('INVALID_STATUS_TRANSITION', 'APPLIED 또는 REVIEWING 상태의 인증만 검토할 수 있습니다.', {
currentStatus: input.currentStatus,
}),
);
}
if (input.decision === 'APPROVED') {
// U018: 필수 인증 서류가 없으면 승인 불가
if (!input.hasDocumentChecklist) {
return failure(
appError('MISSING_DOCUMENTS', '필수 인증 서류가 확인되어야 승인할 수 있습니다.'),
);
}
// U018: 서비스 권역이 없으면 승인 불가
if (input.coverageRegionCount === 0) {
return failure(
appError('MISSING_COVERAGE_REGION', '서비스 권역이 최소 1개 이상 있어야 승인할 수 있습니다.'),
);
}
}
// U020: 제재(SUSPENDED) 시 사유 코드 필수
if (input.decision === 'SUSPENDED' && !input.reasonCode?.trim()) {
return failure(
appError('REASON_REQUIRED', '인증 중지 시 사유 코드는 필수입니다.', { field: 'reasonCode' }),
);
}
// REJECTED 시 사유 코드 필수
if (input.decision === 'REJECTED' && !input.reasonCode?.trim()) {
return failure(
appError('REASON_REQUIRED', '반려 시 사유 코드는 필수입니다.', { field: 'reasonCode' }),
);
}
return success({
status: input.decision as VendorCertificationStatus,
reasonCode: input.reasonCode,
});
}
+28
View File
@@ -0,0 +1,28 @@
import type { VendorCertificationStatus } from './apply-vendor-certification.js';
export interface VendorSearchEntry {
readonly publicId: string;
readonly businessName: string;
readonly vendorType: string;
readonly certificationStatus: VendorCertificationStatus;
readonly isDeleted: boolean;
}
// U019: 검색 결과와 계약 후보에서 제외할 상태
const EXCLUDED_STATUSES = new Set<VendorCertificationStatus>([
'SUSPENDED',
'EXPIRED',
'REJECTED',
'APPLIED',
'REVIEWING',
]);
export function isVendorSearchable(vendor: VendorSearchEntry): boolean {
if (vendor.isDeleted) return false;
if (EXCLUDED_STATUSES.has(vendor.certificationStatus)) return false;
return true;
}
export function filterVendorsForSearch(vendors: readonly VendorSearchEntry[]): VendorSearchEntry[] {
return vendors.filter(isVendorSearchable);
}
+20
View File
@@ -0,0 +1,20 @@
export {
applyVendorCertification,
type VendorCertificationStatus,
type VendorType,
type ApplyVendorCertificationInput,
type VendorCertificationApplication,
} from './apply-vendor-certification.js';
export {
approveVendorCertification,
type VendorCertificationDecision,
type ApproveVendorCertificationInput,
type ApproveVendorCertificationResult,
} from './approve-vendor-certification.js';
export {
isVendorSearchable,
filterVendorsForSearch,
type VendorSearchEntry,
} from './filter-vendor-for-search.js';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@relink/infrastructure",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@prisma/client": "^6.1.0",
"@relink/application": "workspace:*",
"@relink/database": "workspace:*",
"@relink/domain": "workspace:*",
"@relink/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.10.2",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { Prisma } from '@prisma/client';
type TxClient = Prisma.TransactionClient;
export interface CreateAuditLogInput {
readonly resourceType: string;
readonly resourceId: string;
readonly actionType: string;
readonly actorUserId: bigint;
readonly actorRole: string;
readonly reasonCode?: string;
readonly memo?: string;
readonly beforeJson?: Record<string, unknown>;
readonly afterJson?: Record<string, unknown>;
readonly requestId?: string;
readonly correlationId?: string;
readonly ipHash?: string;
readonly userAgent?: string;
}
export async function createAuditLog(tx: TxClient, input: CreateAuditLogInput) {
return tx.auditLog.create({
data: {
resourceType: input.resourceType,
resourceId: input.resourceId,
actionType: input.actionType,
actorUserId: input.actorUserId,
actorRole: input.actorRole,
reasonCode: input.reasonCode ?? null,
memo: input.memo ?? null,
beforeJson: (input.beforeJson as object) ?? undefined,
afterJson: (input.afterJson as object) ?? undefined,
requestId: input.requestId ?? null,
correlationId: input.correlationId ?? null,
ipHash: input.ipHash ?? null,
userAgent: input.userAgent ?? null,
},
});
}
+37
View File
@@ -0,0 +1,37 @@
import type { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
type TxClient = Prisma.TransactionClient;
export interface RecordEventInput {
readonly aggregateType: string;
readonly aggregateId: string;
readonly eventName: string;
readonly eventVersion: number;
readonly payloadJson: Record<string, unknown>;
readonly actorUserId?: bigint;
readonly causationId?: string;
readonly correlationId?: string;
readonly piiLevel?: 'NONE' | 'LOW' | 'HIGH';
readonly occurredAt?: Date;
}
export async function recordEvent(tx: TxClient, input: RecordEventInput) {
const eventKey = `${input.aggregateType}:${input.aggregateId}:${input.eventName}:${randomUUID()}`;
return tx.eventLog.create({
data: {
aggregateType: input.aggregateType,
aggregateId: input.aggregateId,
eventName: input.eventName,
eventVersion: input.eventVersion,
eventKey,
payloadJson: input.payloadJson as object,
actorUserId: input.actorUserId ?? null,
causationId: input.causationId ?? null,
correlationId: input.correlationId ?? null,
piiLevel: input.piiLevel ?? 'NONE',
occurredAt: input.occurredAt ?? new Date(),
},
});
}
@@ -0,0 +1,48 @@
import type { PrismaClient } from '@prisma/client';
export async function isFeatureEnabled(
prisma: PrismaClient,
flagKey: string,
): Promise<boolean> {
const flag = await prisma.featureFlag.findUnique({
where: { flagKey },
select: { isEnabled: true },
});
return flag?.isEnabled ?? false;
}
export async function getFeatureFlags(
prisma: PrismaClient,
): Promise<ReadonlyMap<string, boolean>> {
const flags = await prisma.featureFlag.findMany({
select: { flagKey: true, isEnabled: true },
});
return new Map(flags.map((f) => [f.flagKey, f.isEnabled]));
}
export async function setFeatureFlag(
prisma: PrismaClient,
flagKey: string,
isEnabled: boolean,
description?: string,
) {
const now = new Date();
return prisma.featureFlag.upsert({
where: { flagKey },
update: {
isEnabled,
enabledAt: isEnabled ? now : undefined,
disabledAt: isEnabled ? undefined : now,
},
create: {
flagKey,
isEnabled,
description: description ?? null,
enabledAt: isEnabled ? now : null,
disabledAt: isEnabled ? null : now,
},
});
}
@@ -0,0 +1,51 @@
import type { PrismaClient, Prisma } from '@prisma/client';
type TxClient = Prisma.TransactionClient;
const DEFAULT_TTL_HOURS = 24;
export async function checkIdempotencyKey(
prisma: PrismaClient,
scope: string,
key: string,
): Promise<{ exists: boolean; responseHash: string | null }> {
const existing = await prisma.idempotencyKey.findUnique({
where: { scope_idempotencyKey: { scope, idempotencyKey: key } },
select: { responseHash: true, expiresAt: true },
});
if (!existing) {
return { exists: false, responseHash: null };
}
if (existing.expiresAt && existing.expiresAt < new Date()) {
await prisma.idempotencyKey.delete({
where: { scope_idempotencyKey: { scope, idempotencyKey: key } },
});
return { exists: false, responseHash: null };
}
return { exists: true, responseHash: existing.responseHash };
}
export async function saveIdempotencyKey(
tx: TxClient,
scope: string,
key: string,
requestHash: string,
responseHash: string,
ttlHours: number = DEFAULT_TTL_HOURS,
) {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + ttlHours);
return tx.idempotencyKey.create({
data: {
scope,
idempotencyKey: key,
requestHash,
responseHash,
expiresAt,
},
});
}
+9
View File
@@ -0,0 +1,9 @@
export type { Logger } from './logger.js';
export type { CreateAuditLogInput } from './audit-log.js';
export { createAuditLog } from './audit-log.js';
export type { RecordEventInput } from './event-log.js';
export { recordEvent } from './event-log.js';
export type { EnqueueOutboxInput } from './outbox.js';
export { enqueueOutboxEvent, claimPendingOutboxEvents, markOutboxEventStatus } from './outbox.js';
export { isFeatureEnabled, getFeatureFlags, setFeatureFlag } from './feature-flag.js';
export { checkIdempotencyKey, saveIdempotencyKey } from './idempotency.js';
+9
View File
@@ -0,0 +1,9 @@
/**
* Logger interface for infrastructure layer.
*/
export interface Logger {
info(message: string, meta?: Record<string, unknown>): void;
warn(message: string, meta?: Record<string, unknown>): void;
error(message: string, meta?: Record<string, unknown>): void;
debug(message: string, meta?: Record<string, unknown>): void;
}
+51
View File
@@ -0,0 +1,51 @@
import type { PrismaClient, Prisma } from '@prisma/client';
type TxClient = Prisma.TransactionClient;
type PublishStatusValue = 'PENDING' | 'PUBLISHED' | 'FAILED';
export interface EnqueueOutboxInput {
readonly aggregateType: string;
readonly aggregateId: string;
readonly eventName: string;
readonly payloadJson: Record<string, unknown>;
}
export async function enqueueOutboxEvent(tx: TxClient, input: EnqueueOutboxInput) {
return tx.outboxEvent.create({
data: {
aggregateType: input.aggregateType,
aggregateId: input.aggregateId,
eventName: input.eventName,
payloadJson: input.payloadJson as object,
publishStatus: 'PENDING',
retryCount: 0,
},
});
}
export async function claimPendingOutboxEvents(prisma: PrismaClient, batchSize: number = 50) {
return prisma.outboxEvent.findMany({
where: {
publishStatus: 'PENDING',
availableAt: { lte: new Date() },
},
orderBy: { createdAt: 'asc' },
take: batchSize,
});
}
export async function markOutboxEventStatus(
prisma: PrismaClient,
eventId: bigint,
status: PublishStatusValue,
lastError?: string,
) {
return prisma.outboxEvent.update({
where: { id: eventId },
data: {
publishStatus: status,
retryCount: status === 'FAILED' ? { increment: 1 } : undefined,
lastError: lastError ?? null,
},
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
external: ['@relink/application', '@relink/domain', '@relink/shared'],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@relink/shared",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+18
View File
@@ -0,0 +1,18 @@
export const APP_NAME = 'Re:Link' as const;
export const PAGINATION = {
DEFAULT_PAGE: 1,
DEFAULT_LIMIT: 20,
MAX_LIMIT: 100,
} as const;
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
INTERNAL_SERVER_ERROR: 500,
} as const;
+19
View File
@@ -0,0 +1,19 @@
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.string().url(),
DATABASE_TEST_URL: z.string().url().optional(),
REDIS_URL: z.string().url().optional(),
});
export type Env = z.infer<typeof envSchema>;
export function validateEnv(env: Record<string, string | undefined> = process.env): Env {
const result = envSchema.safeParse(env);
if (!result.success) {
const formatted = result.error.flatten().fieldErrors;
throw new Error(`Invalid environment variables: ${JSON.stringify(formatted)}`);
}
return result.data;
}
+5
View File
@@ -0,0 +1,5 @@
export type { Result, Success, Failure, AppError } from './types/index.js';
export { success, failure, appError } from './types/index.js';
export { envSchema, validateEnv } from './env.js';
export type { Env } from './env.js';
export { APP_NAME, PAGINATION, HTTP_STATUS } from './constants.js';
+37
View File
@@ -0,0 +1,37 @@
import type { AppError } from '../types/app-error.js';
import type { Result } from '../types/result.js';
/**
* Assert that a Result is a Success and return the value.
*/
export function expectSuccess<T>(result: Result<T>): T {
if (!result.ok) {
throw new Error(`Expected success but got failure: ${JSON.stringify(result.error)}`);
}
return result.value;
}
/**
* Assert that a Result is a Failure and return the error.
*/
export function expectFailure<T, E = AppError>(result: Result<T, E>): E {
if (result.ok) {
throw new Error(`Expected failure but got success: ${JSON.stringify(result.value)}`);
}
return result.error;
}
/**
* Create a test database URL with a unique database name for isolation.
*/
export function createTestDatabaseUrl(baseUrl?: string): string {
const base = baseUrl ?? 'postgresql://relink:relink_test@localhost:5433/relink_test';
return base;
}
/**
* Wait for a given number of milliseconds.
*/
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+13
View File
@@ -0,0 +1,13 @@
export interface AppError {
readonly code: string;
readonly message: string;
readonly details?: Record<string, unknown>;
}
export function appError(
code: string,
message: string,
details?: Record<string, unknown>,
): AppError {
return { code, message, details };
}
+4
View File
@@ -0,0 +1,4 @@
export type { Result, Success, Failure } from './result.js';
export { success, failure } from './result.js';
export type { AppError } from './app-error.js';
export { appError } from './app-error.js';
+21
View File
@@ -0,0 +1,21 @@
import type { AppError } from './app-error.js';
export type Result<T, E = AppError> = Success<T> | Failure<E>;
export interface Success<T> {
readonly ok: true;
readonly value: T;
}
export interface Failure<E> {
readonly ok: false;
readonly error: E;
}
export function success<T>(value: T): Success<T> {
return { ok: true, value };
}
export function failure<E>(error: E): Failure<E> {
return { ok: false, error };
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@relink/ui",
"version": "0.0.1",
"private": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.2",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
},
"peerDependencies": {
"react": "^19.0.0"
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { ButtonHTMLAttributes } from 'react';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
readonly variant?: 'primary' | 'secondary' | 'outline';
readonly size?: 'sm' | 'md' | 'lg';
}
export function Button({
variant = 'primary',
size = 'md',
className = '',
children,
...props
}: ButtonProps) {
const baseClasses = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50';
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
outline: 'border border-gray-300 bg-transparent hover:bg-gray-100',
};
const sizeClasses = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
};
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
>
{children}
</button>
);
}
+6
View File
@@ -0,0 +1,6 @@
/**
* @relink/ui - Shared UI components
*/
export { Button } from './button.js';
export type { ButtonProps } from './button.js';
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
}

Some files were not shown because too many files have changed in this diff Show More