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,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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user