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
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { createPrismaClient } from '@relink/database';
const prisma = createPrismaClient();
export async function GET() {
const [regions, industries] = await Promise.all([
prisma.regionHierarchy.findMany({
where: { isActive: true, isBetaEnabled: true },
select: {
code: true,
nameKo: true,
regionType: true,
parentId: true,
depth: true,
isBetaEnabled: true,
},
orderBy: [{ depth: 'asc' }, { sortOrder: 'asc' }],
}),
prisma.industryTaxonomy.findMany({
where: { isActive: true, isBetaEnabled: true, isLeaf: true },
select: {
code: true,
nameKo: true,
depth: true,
isLeaf: true,
isBetaEnabled: true,
},
orderBy: { sortOrder: 'asc' },
}),
]);
return NextResponse.json({
ok: true,
data: { regions, industries },
});
}
@@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createPrismaClient } from '@relink/database';
import { submitStoreService } from '@/services/store-service';
const prisma = createPrismaClient();
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const body = await request.json().catch(() => ({}));
const actorUserId = (body as Record<string, string>).actorUserId;
if (!actorUserId) {
return NextResponse.json(
{ ok: false, error: { code: 'VALIDATION_ERROR', message: 'actorUserId는 필수입니다.' } },
{ status: 400 },
);
}
const result = await submitStoreService(prisma, id, actorUserId);
if (!result.ok) {
const status = result.error.code === 'NOT_FOUND' ? 404 : 422;
return NextResponse.json({ ok: false, error: result.error }, { status });
}
return NextResponse.json({ ok: true, data: result.value });
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createPrismaClient } from '@relink/database';
import { createStoreDraftService } from '@/services/store-service';
import { z } from 'zod';
const prisma = createPrismaClient();
const createStoreSchema = z.object({
ownerUserId: z.string().min(1),
listingTitle: z.string().min(1),
industryLeafCode: z.string().min(1),
regionClusterCode: z.string().min(1),
roadAddress: z.string().min(1),
detailAddress: z.string().optional(),
lease: z
.object({
depositAmount: z.number().min(0),
monthlyRentAmount: z.number().min(0),
premiumAmount: z.number().min(0),
maintenanceFeeAmount: z.number().min(0).optional(),
remainingLeaseMonths: z.number().int().min(0).optional(),
transferable: z.boolean().optional(),
})
.optional(),
facility: z
.object({
exclusiveAreaSqm: z.number().positive(),
seatCount: z.number().int().min(0),
floorLevel: z.number().int().optional(),
hasGas: z.boolean().optional(),
hasDrainage: z.boolean().optional(),
hasDuct: z.boolean().optional(),
electricCapacityKw: z.number().min(0).optional(),
kitchenEquipmentSummary: z.string().optional(),
parkingCount: z.number().int().min(0).optional(),
})
.optional(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = createStoreSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: { code: 'VALIDATION_ERROR', message: parsed.error.message } },
{ status: 400 },
);
}
const result = await createStoreDraftService(prisma, parsed.data);
if (!result.ok) {
const status = result.error.code === 'VALIDATION_ERROR' ? 400 : 422;
return NextResponse.json({ ok: false, error: result.error }, { status });
}
return NextResponse.json({ ok: true, data: result.value }, { status: 201 });
}