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:
@@ -0,0 +1 @@
|
||||
DATABASE_URL="postgresql://relink:relink_dev@localhost:5432/relink_dev"
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user