Rename project from Re:Link to Startover

Rebrand repository from "Re:Link" to "Startover" across the codebase. Updates include package names and scopes (@relink/* -> @startover/*), import paths, Next.js transpile settings, vitest name, UI text and docs, Dockerfile and CI/workflow names, deploy scripts and repo paths, and example/production env values. Also add auth-related env vars, an apps/web .env symlink, and small formatting/typing cleanups in several TSX/TS files and tests to accommodate the rename.
This commit is contained in:
Johngreen
2026-03-08 20:22:08 +09:00
parent 557559c654
commit 7f59b94dcf
101 changed files with 361 additions and 281 deletions
+6
View File
@@ -9,5 +9,11 @@ REDIS_URL="redis://localhost:6379"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_ADMIN_URL="http://localhost:3001"
# Auth.js
AUTH_SECRET="iSceQlYVZ5fhUQdMyFBC0Z8kMo3nMfJRtyPSxbFo6Eg="
NEXTAUTH_URL="http://localhost:4100"
AUTH_KAKAO_CLIENT_ID="3644464b875f62dc139fe75fef96477c"
AUTH_KAKAO_CLIENT_SECRET="cjijqCD14S5JXyfjeiatFIxIMU1w9Ldr"
# Node
NODE_ENV="development"
+2 -2
View File
@@ -1,6 +1,6 @@
# Database
DATABASE_URL="postgresql://relink:relink_dev@localhost:5432/relink_dev"
DATABASE_TEST_URL="postgresql://relink:relink_test@localhost:5433/relink_test"
DATABASE_URL="postgresql://startover:startover_dev@localhost:5432/startover_dev"
DATABASE_TEST_URL="postgresql://startover:startover_test@localhost:5433/startover_test"
# Redis
REDIS_URL="redis://localhost:6379"
+3 -3
View File
@@ -1,12 +1,12 @@
# ===========================================
# Re:Link Production Environment Variables
# Startover Production Environment Variables
# Copy to .env.production and fill in values
# ===========================================
# Database
DB_USER=relink
DB_USER=startover
DB_PASSWORD=CHANGE_ME_STRONG_PASSWORD
DB_NAME=relink_prod
DB_NAME=startover_prod
# Redis
REDIS_PASSWORD=CHANGE_ME_STRONG_PASSWORD
+1 -1
View File
@@ -1,4 +1,4 @@
name: Deploy Re:Link
name: Deploy Startover
on:
push:
+4 -4
View File
@@ -1,4 +1,4 @@
# Re:Link (리링크) 실행형 개발계획서
# Startover (스타트오버) 실행형 개발계획서
> 폐업-철거-인테리어 선순환 통합 매칭 플랫폼
> 작성일: 2026-03-07 | 기준 문서: 2026년 예비창업패키지 사업계획서
@@ -8,7 +8,7 @@
## 1. 문서 목적
이 문서는 단순 기능 목록이 아니라, Re:Link MVP를 실제로 만들기 위해 먼저 고정해야 할 **정책, 운영, 계측, 아키텍처, 일정**을 정리한 실행 기준 문서다.
이 문서는 단순 기능 목록이 아니라, Startover MVP를 실제로 만들기 위해 먼저 고정해야 할 **정책, 운영, 계측, 아키텍처, 일정**을 정리한 실행 기준 문서다.
### 1-1. 문서 역할
@@ -19,7 +19,7 @@
### 1-2. 토스식 의사결정 원칙
Re:Link의 MVP 의사결정은 아래 원칙을 기본값으로 삼는다.
Startover의 MVP 의사결정은 아래 원칙을 기본값으로 삼는다.
| 원칙 | 적용 방식 |
|------|-----------|
@@ -529,7 +529,7 @@ re-link/
## 14. 최종 정리
Re:Link의 MVP는 단순한 매칭 앱이 아니라, **정책과 운영으로 신뢰를 만드는 거래 플랫폼**이다. 따라서 개발 우선순위는 `예쁜 UI``기능 수`가 아니라 아래 순서를 따라야 한다.
Startover의 MVP는 단순한 매칭 앱이 아니라, **정책과 운영으로 신뢰를 만드는 거래 플랫폼**이다. 따라서 개발 우선순위는 `예쁜 UI``기능 수`가 아니라 아래 순서를 따라야 한다.
1. 정책 고정
2. 운영 도구 구축
+2 -2
View File
@@ -1,5 +1,5 @@
# ============================================
# Re:Link Turborepo Multi-stage Dockerfile
# Startover Turborepo Multi-stage Dockerfile
# Usage: docker build --build-arg APP_NAME=web .
# ============================================
@@ -32,7 +32,7 @@ COPY . .
# Generate Prisma client & build
RUN cd packages/database && npx prisma generate
RUN pnpm turbo run build --filter=@relink/${APP_NAME}
RUN pnpm turbo run build --filter=@startover/${APP_NAME}
# Ensure public directories exist for COPY
RUN mkdir -p /app/apps/${APP_NAME}/public
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@relink/ui', '@relink/shared'],
transpilePackages: ['@startover/ui', '@startover/shared'],
};
export default nextConfig;
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@relink/admin",
"name": "@startover/admin",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -12,8 +12,8 @@
"clean": "rm -rf .next"
},
"dependencies": {
"@relink/shared": "workspace:*",
"@relink/ui": "workspace:*",
"@startover/shared": "workspace:*",
"@startover/ui": "workspace:*",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
+2 -2
View File
@@ -3,8 +3,8 @@ import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Re:Link Admin',
description: 'Re:Link 운영 콘솔',
title: 'Startover Admin',
description: 'Startover 운영 콘솔',
};
export default function RootLayout({
+1 -1
View File
@@ -1,7 +1,7 @@
export default function AdminHomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center">
<h1 className="text-4xl font-bold">Re:Link Admin</h1>
<h1 className="text-4xl font-bold">Startover Admin</h1>
<p className="mt-4 text-lg text-gray-600"> </p>
</main>
);
+1
View File
@@ -0,0 +1 @@
/Users/johngreen/Dev/Re_Link/.env
+1 -1
View File
@@ -2,7 +2,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@relink/ui', '@relink/shared'],
transpilePackages: ['@startover/ui', '@startover/shared'],
};
export default nextConfig;
+6 -6
View File
@@ -1,5 +1,5 @@
{
"name": "@relink/web",
"name": "@startover/web",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -15,11 +15,11 @@
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@prisma/client": "^6.1.0",
"@relink/database": "workspace:*",
"@relink/domain": "workspace:*",
"@relink/infrastructure": "workspace:*",
"@relink/shared": "workspace:*",
"@relink/ui": "workspace:*",
"@startover/database": "workspace:*",
"@startover/domain": "workspace:*",
"@startover/infrastructure": "workspace:*",
"@startover/shared": "workspace:*",
"@startover/ui": "workspace:*",
"argon2": "^0.44.0",
"next": "^15.1.0",
"next-auth": "5.0.0-beta.30",
@@ -5,7 +5,7 @@ import {
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
} from '@startover/database';
import {
createStoreDraftService,
submitStoreService,
@@ -5,7 +5,7 @@ import {
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
} from '@startover/database';
import {
createStoreDraftService,
submitStoreService,
@@ -71,7 +71,12 @@ describe('Match Request Service Integration Tests', () => {
if (!createResult.ok) throw new Error('createStoreDraft failed');
await submitStoreService(prisma, createResult.value.publicId, ownerUserId.toString());
await reviewStoreService(prisma, createResult.value.publicId, 'APPROVED', operatorUserId.toString());
await reviewStoreService(
prisma,
createResult.value.publicId,
'APPROVED',
operatorUserId.toString(),
);
const policyVersion = await prisma.policyVersion.create({
data: {
@@ -5,7 +5,7 @@ import {
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
} from '@startover/database';
import {
createStoreDraftService,
submitStoreService,
@@ -5,7 +5,7 @@ import {
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
} from '@startover/database';
import {
createStoreDraftService,
submitStoreService,
@@ -5,7 +5,7 @@ import {
teardownTestDatabase,
cleanAllTables,
seedTestMasterData,
} from '@relink/database';
} from '@startover/database';
import {
applyVendorCertificationService,
reviewVendorCertificationService,
+1 -1
View File
@@ -1,5 +1,5 @@
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import { releaseEscrowService } from '@/services/contract-service';
import ContractActionButtons from './ContractActionButtons';
+17 -4
View File
@@ -4,7 +4,7 @@ export default function AdminDashboardPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-1 text-sm text-gray-500">Re:Link </p>
<p className="mt-1 text-sm text-gray-500">Startover </p>
{/* KPI 요약 */}
<div className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
@@ -75,10 +75,23 @@ export default function AdminDashboardPage() {
{[
{ action: '매장 승인', target: '강남역 카페 양도', actor: '김운영', time: '10분 전' },
{ action: '업체 인증 승인', target: '(주)클린철거', actor: '이운영', time: '1시간 전' },
{ action: '에스크로 입금 확인', target: '선릉역 한식당 계약', actor: 'SYSTEM', time: '2시간 전' },
{ action: '매칭 요청 수락', target: '홍대 디저트카페', actor: '박운영', time: '3시간 전' },
{
action: '에스크로 입금 확인',
target: '선릉역 한식당 계약',
actor: 'SYSTEM',
time: '2시간 전',
},
{
action: '매칭 요청 수락',
target: '홍대 디저트카페',
actor: '박운영',
time: '3시간 전',
},
].map((log, i) => (
<div key={i} className="flex items-center justify-between border-b border-gray-50 py-2 last:border-0">
<div
key={i}
className="flex items-center justify-between border-b border-gray-50 py-2 last:border-0"
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-900">{log.action}</span>
<span className="text-sm text-gray-500">{log.target}</span>
@@ -1,6 +1,6 @@
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import { InviteForm } from './invite-form';
const prisma = createPrismaClient();
+1 -1
View File
@@ -1,5 +1,5 @@
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import { reviewStoreService } from '@/services/store-service';
import StoreActionButtons from './StoreActionButtons';
+1 -1
View File
@@ -1,5 +1,5 @@
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import { reviewSubsidyCaseService } from '@/services/subsidy-case-service';
import SubsidyActionButtons from './SubsidyActionButtons';
+1 -1
View File
@@ -1,5 +1,5 @@
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import { reviewVendorCertificationService } from '@/services/vendor-certification-service';
import VendorActionButtons from './VendorActionButtons';
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import type { ConsentType, ProfileType, UserRole } from '@prisma/client';
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import argon2 from 'argon2';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
const prisma = createPrismaClient();
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import type { UserRole } from '@prisma/client';
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
const prisma = createPrismaClient();
@@ -1,13 +1,10 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import { submitStoreService } from '@/services/store-service';
const prisma = createPrismaClient();
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
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;
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import { createStoreDraftService } from '@/services/store-service';
import { z } from 'zod';
+1 -1
View File
@@ -2,7 +2,7 @@
import { z } from 'zod';
import argon2 from 'argon2';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
const prisma = createPrismaClient();
import { randomBytes } from 'crypto';
+1 -1
View File
@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
const prisma = createPrismaClient();
+16 -5
View File
@@ -1,5 +1,5 @@
import Link from 'next/link';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
export const dynamic = 'force-dynamic';
@@ -74,10 +74,19 @@ export default async function ContractsPage() {
<p className="text-center text-sm text-gray-500"> </p>
) : (
contracts.map((contract) => {
const statusInfo = STATUS_MAP[contract.status] ?? { label: contract.status, color: 'bg-gray-100 text-gray-700' };
const escrowInfo = ESCROW_MAP[contract.escrowStatus] ?? { label: contract.escrowStatus, color: 'text-gray-500' };
const statusInfo = STATUS_MAP[contract.status] ?? {
label: contract.status,
color: 'bg-gray-100 text-gray-700',
};
const escrowInfo = ESCROW_MAP[contract.escrowStatus] ?? {
label: contract.escrowStatus,
color: 'text-gray-500',
};
return (
<div key={contract.publicId} className="rounded-lg border border-gray-200 bg-white p-5">
<div
key={contract.publicId}
className="rounded-lg border border-gray-200 bg-white p-5"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
@@ -90,7 +99,9 @@ export default async function ContractsPage() {
: {new Date(contract.createdAt).toLocaleDateString('ko-KR')}
</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}
>
{statusInfo.label}
</span>
</div>
+3 -3
View File
@@ -6,8 +6,8 @@ import { AuthButtons } from './auth-buttons';
import './globals.css';
export const metadata: Metadata = {
title: 'Re:Link',
description: 'Re:Link - 폐업 · 양도 · 창업을 잇는 중개 플랫폼',
title: 'Startover',
description: 'Startover - 폐업 · 양도 · 창업을 잇는 중개 플랫폼',
};
const OPERATOR_ROLES = [
@@ -26,7 +26,7 @@ async function Navigation() {
<nav className="border-b border-gray-200 bg-white">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<Link href="/" className="text-xl font-bold text-blue-600">
Re:Link
Startover
</Link>
<div className="flex items-center gap-6">
<Link href="/stores" className="text-sm text-gray-700 hover:text-blue-600">
+9 -6
View File
@@ -1,5 +1,5 @@
import Link from 'next/link';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
export const dynamic = 'force-dynamic';
@@ -42,7 +42,10 @@ export default async function MatchingPage() {
<p className="text-center text-sm text-gray-500"> </p>
) : (
requests.map((req) => {
const statusInfo = STATUS_LABELS[req.status] ?? { label: req.status, color: 'bg-gray-100 text-gray-700' };
const statusInfo = STATUS_LABELS[req.status] ?? {
label: req.status,
color: 'bg-gray-100 text-gray-700',
};
return (
<div key={req.publicId} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="flex items-start justify-between">
@@ -53,14 +56,14 @@ export default async function MatchingPage() {
{MATCH_TYPE_LABELS[req.matchType] ?? req.matchType}
</span>
</div>
{req.message && (
<p className="mt-1 text-sm text-gray-600">{req.message}</p>
)}
{req.message && <p className="mt-1 text-sm text-gray-600">{req.message}</p>}
<p className="mt-2 text-xs text-gray-400">
: {new Date(req.createdAt).toLocaleDateString('ko-KR')}
</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}
>
{statusInfo.label}
</span>
</div>
+13 -4
View File
@@ -4,7 +4,7 @@ export default function HomePage() {
return (
<main className="mx-auto max-w-7xl px-4 py-16">
<div className="text-center">
<h1 className="text-5xl font-bold text-gray-900">Re:Link</h1>
<h1 className="text-5xl font-bold text-gray-900">Startover</h1>
<p className="mt-4 text-xl text-gray-600"> · · </p>
<p className="mt-2 text-gray-500">
1 ··
@@ -32,7 +32,10 @@ export default function HomePage() {
<p className="mt-2 text-sm text-gray-600">
, , .
</p>
<Link href="/stores/new" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
<Link
href="/stores/new"
className="mt-3 inline-block text-sm text-blue-600 hover:underline"
>
</Link>
</div>
@@ -66,7 +69,10 @@ export default function HomePage() {
<p className="mt-2 text-sm text-gray-600">
, .
</p>
<Link href="/subsidies" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
<Link
href="/subsidies"
className="mt-3 inline-block text-sm text-blue-600 hover:underline"
>
</Link>
</div>
@@ -76,7 +82,10 @@ export default function HomePage() {
<p className="mt-2 text-sm text-gray-600">
, , .
</p>
<Link href="/contracts" className="mt-3 inline-block text-sm text-blue-600 hover:underline">
<Link
href="/contracts"
className="mt-3 inline-block text-sm text-blue-600 hover:underline"
>
</Link>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
export const dynamic = 'force-dynamic';
+1 -1
View File
@@ -1,6 +1,6 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
export const dynamic = 'force-dynamic';
+1 -1
View File
@@ -1,5 +1,5 @@
import Link from 'next/link';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
export const dynamic = 'force-dynamic';
+9 -4
View File
@@ -1,5 +1,5 @@
import Link from 'next/link';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
export const dynamic = 'force-dynamic';
@@ -40,7 +40,7 @@ export default async function SubsidiesPage() {
<div className="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 className="text-sm font-semibold text-blue-800"> </h3>
<p className="mt-1 text-sm text-blue-700">
Re:Link은 , · · ·
Startover은 , · · ·
.
</p>
</div>
@@ -51,7 +51,10 @@ export default async function SubsidiesPage() {
<p className="text-center text-sm text-gray-500"> </p>
) : (
cases.map((c) => {
const statusInfo = STATUS_MAP[c.status] ?? { label: c.status, color: 'bg-gray-100 text-gray-700' };
const statusInfo = STATUS_MAP[c.status] ?? {
label: c.status,
color: 'bg-gray-100 text-gray-700',
};
const total = c.checklistItems.length;
const checked = c.checklistItems.filter((item) => item.status === 'CHECKED').length;
return (
@@ -63,7 +66,9 @@ export default async function SubsidiesPage() {
: {new Date(c.createdAt).toLocaleDateString('ko-KR')}
</p>
</div>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${statusInfo.color}`}
>
{statusInfo.label}
</span>
</div>
+2 -2
View File
@@ -1,9 +1,9 @@
'use server';
import { revalidatePath } from 'next/cache';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import { applyVendorCertificationService } from '@/services/vendor-certification-service';
import type { VendorType } from '@relink/domain';
import type { VendorType } from '@startover/domain';
const prisma = createPrismaClient();
+1 -1
View File
@@ -1,4 +1,4 @@
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import VendorApplicationForm from './vendor-application-form';
export const dynamic = 'force-dynamic';
+4 -4
View File
@@ -2,7 +2,7 @@ import NextAuth from 'next-auth';
import type { NextAuthConfig } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Kakao from 'next-auth/providers/kakao';
import { createPrismaClient } from '@relink/database';
import { createPrismaClient } from '@startover/database';
import argon2 from 'argon2';
import type { UserRole } from '@prisma/client';
@@ -193,7 +193,7 @@ const fullConfig: NextAuthConfig = {
return {
id: String(profile.id),
name: kakaoProfile?.nickname ?? null,
email: (kakaoAccount?.email as string) || `kakao_${profile.id}@placeholder.relink`,
email: (kakaoAccount?.email as string) || `kakao_${profile.id}@placeholder.startover`,
image: kakaoProfile?.profile_image_url ?? null,
};
},
@@ -239,11 +239,11 @@ const fullConfig: NextAuthConfig = {
},
async signIn({ user, account }) {
if (account?.provider === 'kakao') {
// placeholder 이메일(@placeholder.relink)은 비즈 앱 전환 전 임시 처리
// placeholder 이메일(@placeholder.startover)은 비즈 앱 전환 전 임시 처리
const email = user.email;
if (!email) return false;
const isPlaceholder = email.endsWith('@placeholder.relink');
const isPlaceholder = email.endsWith('@placeholder.startover');
const existing = !isPlaceholder
? await prisma.user.findFirst({
where: { emailNormalized: email.toLowerCase().trim() },
+3 -3
View File
@@ -5,9 +5,9 @@ import {
checkIdempotency,
openDispute,
type ContractType,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
} from '@startover/domain';
import { createAuditLog, enqueueOutboxEvent } from '@startover/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
// ---------------------------------------------------------------------------
// I012: 계약 생성 서비스
+20 -6
View File
@@ -6,9 +6,9 @@ import {
type MatchType,
type MatchSourceType,
type StoreSearchCriteria,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
} from '@startover/domain';
import { createAuditLog, enqueueOutboxEvent } from '@startover/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export interface CreateMatchRequestServiceInput {
readonly storePublicId: string;
@@ -34,7 +34,9 @@ export async function createMatchRequestService(
});
if (!store) {
return failure(appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId: input.storePublicId }));
return failure(
appError('NOT_FOUND', '매장을 찾을 수 없습니다.', { storePublicId: input.storePublicId }),
);
}
const openRequest = await prisma.matchRequest.findFirst({
@@ -118,7 +120,9 @@ export async function acceptMatchRequestService(
});
if (!matchRequest) {
return failure(appError('NOT_FOUND', '매칭 요청을 찾을 수 없습니다.', { matchRequestPublicId }));
return failure(
appError('NOT_FOUND', '매칭 요청을 찾을 수 없습니다.', { matchRequestPublicId }),
);
}
const domainResult = acceptMatchRequest({
@@ -172,7 +176,17 @@ export async function acceptMatchRequestService(
export async function searchStoresService(
prisma: PrismaClient,
criteria: StoreSearchCriteria,
): Promise<Result<{ stores: Array<{ publicId: string; listingTitle: string; dealStatus: string }>; total: number; page: number; limit: number }, AppError>> {
): Promise<
Result<
{
stores: Array<{ publicId: string; listingTitle: string; dealStatus: string }>;
total: number;
page: number;
limit: number;
},
AppError
>
> {
const defaults = buildSearchDefaults(criteria);
const where: Record<string, unknown> = {
+3 -3
View File
@@ -12,9 +12,9 @@ import {
type StoreData,
type ViewerContext,
type FilteredStoreData,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
} from '@startover/domain';
import { createAuditLog, enqueueOutboxEvent } from '@startover/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
function buildRegionChecker(regions: { code: string; isBetaEnabled: boolean }[]): RegionChecker {
const betaCodes = new Set(regions.filter((r) => r.isBetaEnabled).map((r) => r.code));
@@ -4,9 +4,9 @@ import {
reviewSubsidyCase,
type ChecklistItemTemplate,
type SubsidyReviewDecision,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
} from '@startover/domain';
import { createAuditLog, enqueueOutboxEvent } from '@startover/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export interface CreateSubsidyCaseServiceInput {
readonly storePublicId: string;
@@ -5,9 +5,9 @@ import {
approveVendorCertification,
type VendorType,
type VendorCertificationDecision,
} from '@relink/domain';
import { createAuditLog, enqueueOutboxEvent } from '@relink/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
} from '@startover/domain';
import { createAuditLog, enqueueOutboxEvent } from '@startover/infrastructure';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export interface ApplyVendorCertificationServiceInput {
readonly ownerUserId: string;
+1 -1
View File
@@ -3,7 +3,7 @@ import { resolve } from 'path';
export default defineConfig({
test: {
name: '@relink/web',
name: '@startover/web',
globals: true,
environment: 'node',
testTimeout: 30000,
+3 -3
View File
@@ -2,14 +2,14 @@
set -euo pipefail
# ===========================================
# Re:Link Auto Deploy Script
# Startover Auto Deploy Script
# Triggered by Gitea webhook on push to main
# ===========================================
APP_DIR="$HOME/relink"
APP_DIR="$HOME/startover"
LOG_FILE="$APP_DIR/deploy.log"
LOCK_FILE="$APP_DIR/deploy.lock"
GITEA_REPO="http://39.117.244.52:3000/geonhee/Re_Link.git"
GITEA_REPO="http://39.117.244.52:3000/geonhee/startover.git"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
+3 -3
View File
@@ -1,14 +1,14 @@
#!/bin/bash
# ===========================================
# Re:Link Poll-based Auto Deploy
# Startover Poll-based Auto Deploy
# Checks Gitea for new commits every minute
# ===========================================
APP_DIR="$HOME/relink"
APP_DIR="$HOME/startover"
REPO_DIR="$APP_DIR/repo"
LOG_FILE="$APP_DIR/deploy.log"
LOCK_FILE="$APP_DIR/deploy.lock"
GITEA_REPO="http://39.117.244.52:3000/geonhee/Re_Link.git"
GITEA_REPO="http://39.117.244.52:3000/geonhee/startover.git"
BRANCH="main"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"; }
+4 -4
View File
@@ -2,11 +2,11 @@
set -euo pipefail
# ===========================================
# Re:Link IDC Server Setup Script
# Startover IDC Server Setup Script
# Run once on the IDC server to initialize
# ===========================================
echo "=== Re:Link Server Setup ==="
echo "=== Startover Server Setup ==="
# 1. Install Docker Compose V2 plugin
echo "[1/5] Installing Docker Compose V2 plugin..."
@@ -31,7 +31,7 @@ echo "Act Runner v${ACT_RUNNER_VERSION} installed."
# 3. Create app directory
echo "[3/5] Creating application directory..."
APP_DIR="$HOME/relink"
APP_DIR="$HOME/startover"
mkdir -p "$APP_DIR"
cd "$APP_DIR"
@@ -71,7 +71,7 @@ echo " cd $APP_DIR"
echo " act_runner register \\"
echo " --instance http://39.117.244.52:3000 \\"
echo " --token <REGISTRATION_TOKEN> \\"
echo " --name relink-runner \\"
echo " --name startover-runner \\"
echo " --labels ubuntu-latest:host"
echo ""
echo "2. Start the runner service:"
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Re:Link Gitea Webhook Receiver
Startover Gitea Webhook Receiver
Listens for push events and triggers deployment.
"""
@@ -13,11 +13,11 @@ import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "relink-deploy-secret")
DEPLOY_SCRIPT = os.path.expanduser("~/relink/deploy.sh")
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "startover-deploy-secret")
DEPLOY_SCRIPT = os.path.expanduser("~/startover/deploy.sh")
DEPLOY_BRANCH = "main"
PORT = int(os.environ.get("WEBHOOK_PORT", "9000"))
LOG_FILE = os.path.expanduser("~/relink/webhook.log")
LOG_FILE = os.path.expanduser("~/startover/webhook.log")
def log(msg: str):
+5 -5
View File
@@ -5,13 +5,13 @@ services:
ports:
- "127.0.0.1:5432:5432"
environment:
POSTGRES_USER: ${DB_USER:-relink}
POSTGRES_USER: ${DB_USER:-startover}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
POSTGRES_DB: ${DB_NAME:-relink_prod}
POSTGRES_DB: ${DB_NAME:-startover_prod}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-relink} -d ${DB_NAME:-relink_prod}"]
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-startover} -d ${DB_NAME:-startover_prod}"]
interval: 10s
timeout: 5s
retries: 5
@@ -57,7 +57,7 @@ services:
ports:
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: postgresql://${DB_USER:-relink}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-relink_prod}?schema=public
DATABASE_URL: postgresql://${DB_USER:-startover}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-startover_prod}?schema=public
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?NEXTAUTH_SECRET is required}
@@ -88,7 +88,7 @@ services:
ports:
- "127.0.0.1:3001:3000"
environment:
DATABASE_URL: postgresql://${DB_USER:-relink}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-relink_prod}?schema=public
DATABASE_URL: postgresql://${DB_USER:-startover}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-startover_prod}?schema=public
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
NODE_ENV: production
labels:
+8 -8
View File
@@ -4,13 +4,13 @@ services:
ports:
- "5432:5432"
environment:
POSTGRES_USER: relink
POSTGRES_PASSWORD: relink_dev
POSTGRES_DB: relink_dev
POSTGRES_USER: startover
POSTGRES_PASSWORD: startover_dev
POSTGRES_DB: startover_dev
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U relink -d relink_dev"]
test: ["CMD-SHELL", "pg_isready -U startover -d startover_dev"]
interval: 5s
timeout: 5s
retries: 5
@@ -20,11 +20,11 @@ services:
ports:
- "5433:5432"
environment:
POSTGRES_USER: relink
POSTGRES_PASSWORD: relink_test
POSTGRES_DB: relink_test
POSTGRES_USER: startover
POSTGRES_PASSWORD: startover_test
POSTGRES_DB: startover_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U relink -d relink_test"]
test: ["CMD-SHELL", "pg_isready -U startover -d startover_test"]
interval: 5s
timeout: 5s
retries: 5
+2 -2
View File
@@ -1,4 +1,4 @@
# Re:Link 이벤트 스키마 정의서
# Startover 이벤트 스키마 정의서
> 문서 코드: D004
> 버전: 1.0.0
@@ -23,7 +23,7 @@
### 1-1. 목적
이 문서는 Re:Link 플랫폼에서 `EventLog` 테이블에 저장되는 도메인 이벤트의 규격을 정의한다.
이 문서는 Startover 플랫폼에서 `EventLog` 테이블에 저장되는 도메인 이벤트의 규격을 정의한다.
이벤트 로그는 세 가지 목적으로 활용된다.
+2 -2
View File
@@ -1,8 +1,8 @@
# Re:Link `schema.prisma` 설계 초안
# Startover `schema.prisma` 설계 초안
## 목적
이 문서는 Re:Link MVP의 데이터 모델을 실제 `schema.prisma`로 내리기 전에, 엔티티/필드/관계/인덱스/제약 조건을 고정하기 위한 초안이다.
이 문서는 Startover MVP의 데이터 모델을 실제 `schema.prisma`로 내리기 전에, 엔티티/필드/관계/인덱스/제약 조건을 고정하기 위한 초안이다.
대상 범위는 `assisted marketplace MVP`이며, 아래 원칙을 따른다.
+2 -2
View File
@@ -1,8 +1,8 @@
# Re:Link 베타 마스터 데이터 초안
# Startover 베타 마스터 데이터 초안
## 목적
이 문서는 Re:Link MVP 베타에서 사용할 지역/업종 마스터 데이터의 구조와 초기 seed 원칙을 정의한다.
이 문서는 Startover MVP 베타에서 사용할 지역/업종 마스터 데이터의 구조와 초기 seed 원칙을 정의한다.
핵심 목표는 아래 3가지다.
+5 -5
View File
@@ -1,15 +1,15 @@
# Re:Link 계약-에스크로-분쟁 정책서
# Startover 계약-에스크로-분쟁 정책서
> 문서 버전: 1.0.0
> 기준일: 2026-03-07
> 적용 범위: Re:Link MVP Phase 1
> 적용 범위: Startover MVP Phase 1
> DRI: 대표 (제품 정책), FINANCE_OPERATOR (정산 실행), TRUST_OPERATOR (계약·검수·분쟁)
---
## 목적
이 문서는 Re:Link 플랫폼에서 계약 생성, 전자서명 증적 수집, 에스크로 결제, 검수 절차, 정산 해제, 분쟁 처리, 환불, 수동 보정에 관한 운영 기준을 정의한다. 모든 개발 구현과 운영 의사결정은 이 문서를 최우선 기준으로 삼는다.
이 문서는 Startover 플랫폼에서 계약 생성, 전자서명 증적 수집, 에스크로 결제, 검수 절차, 정산 해제, 분쟁 처리, 환불, 수동 보정에 관한 운영 기준을 정의한다. 모든 개발 구현과 운영 의사결정은 이 문서를 최우선 기준으로 삼는다.
---
@@ -91,7 +91,7 @@ DRAFT -> GENERATED -> SIGNING -> SIGNED -> IN_PROGRESS -> COMPLETED
| 문서 버전 | `contractVersionId` | 서명 당시 적용된 `ContractVersion`의 ID |
| 문서 해시 | `documentHash` | 서명 당시 문서의 SHA-256 해시 (저장 해시와 일치 여부 검증용) |
| 증적 유형 | `evidenceType` | 서명 방식 코드 (아래 참조) |
| 제공자 코드 | `providerCode` | 서명 기술 제공자 (예: `RELINK_CHECKBOX`, `KAKAOPAY_SIGN`) |
| 제공자 코드 | `providerCode` | 서명 기술 제공자 (예: `STARTOVER_CHECKBOX`, `KAKAOPAY_SIGN`) |
| 서명 해시 | `signatureHash` | 서명 행위 자체에 대한 해시 (재현 가능한 증적) |
### 2-2. MVP 서명 방식
@@ -582,4 +582,4 @@ MVP Phase 1에서 아래 기능은 feature flag 뒤에 두고 기본값은 비
---
*이 문서는 Re:Link MVP 운영 기준 문서이며, 구현 코드보다 이 문서가 우선합니다. 정책 변경이 필요한 경우 이 문서를 먼저 개정하고 개정 이력을 기록한 뒤 코드 구현에 반영합니다.*
*이 문서는 Startover MVP 운영 기준 문서이며, 구현 코드보다 이 문서가 우선합니다. 정책 변경이 필요한 경우 이 문서를 먼저 개정하고 개정 이력을 기록한 뒤 코드 구현에 반영합니다.*
+8 -8
View File
@@ -1,6 +1,6 @@
# 폐업 정보 공개 정책서
> Re:Link MVP — 정보 공개 기준 및 역할별 열람 권한 정책
> Startover MVP — 정보 공개 기준 및 역할별 열람 권한 정책
> 문서 버전: v1.0.0
> 최초 작성: 2026-03-07
> 적용 범위: Phase 1 MVP 전체, Phase 2 데이터 구조 준비 포함
@@ -23,7 +23,7 @@
## 1. 정보 공개 등급 체계
Re:Link는 폐업자의 민감한 정보를 보호하면서 거래 목적에 맞는 정보만 단계적으로 공개하는 4단계 등급 체계를 운영한다. 폐업 등록 즉시 모든 정보가 공개되지 않으며, 매칭 진행 상황과 역할에 따라 정보 접근이 제한적으로 확대된다.
Startover는 폐업자의 민감한 정보를 보호하면서 거래 목적에 맞는 정보만 단계적으로 공개하는 4단계 등급 체계를 운영한다. 폐업 등록 즉시 모든 정보가 공개되지 않으며, 매칭 진행 상황과 역할에 따라 정보 접근이 제한적으로 확대된다.
### 1-1. 등급 정의
@@ -252,7 +252,7 @@ VENDOR_MANAGER의 경우 연락처는 운영자 중개 방식을 우선 사용
### 5-1. 동의 수집 항목
Re:Link`UserConsent` 모델을 통해 동의 유형별로 수집 시점, 버전, 철회 가능 여부를 관리한다.
Startover`UserConsent` 모델을 통해 동의 유형별로 수집 시점, 버전, 철회 가능 여부를 관리한다.
| 동의 항목 | `consentType` 코드 | 수집 시점 | 철회 가능 여부 |
|----------|-------------------|-----------|----------------|
@@ -397,7 +397,7 @@ INTERNAL 정보에 접근하는 운영자 API는 아래 추가 보호를 적용
### 8-1. PII 식별 및 분리 저장
Re:Link에서 개인정보(PII)로 분류하는 항목과 저장 방식은 아래와 같다.
Startover에서 개인정보(PII)로 분류하는 항목과 저장 방식은 아래와 같다.
| 분류 | 항목 | 저장 방식 | 접근 통제 |
|------|------|----------|----------|
@@ -407,11 +407,11 @@ Re:Link에서 개인정보(PII)로 분류하는 항목과 저장 방식은 아
| 위치 정보 | 상세 주소 | 암호화 저장 | MATCHED_ONLY 이상 |
| 사업자 정보 | 사업자등록번호 | 평문 저장 (공개 정보) | MATCHED_ONLY 이상 |
사업자등록번호는 공개 정보이나, Re:Link 내에서는 계약 목적으로만 사용하며 검색 노출은 MATCHED_ONLY로 제한한다.
사업자등록번호는 공개 정보이나, Startover 내에서는 계약 목적으로만 사용하며 검색 노출은 MATCHED_ONLY로 제한한다.
### 8-2. 개인정보 수집 및 이용 목적
Re:Link가 수집하는 개인정보는 아래 목적으로만 사용한다.
Startover가 수집하는 개인정보는 아래 목적으로만 사용한다.
| 수집 목적 | 해당 항목 | 보관 기간 |
|----------|----------|----------|
@@ -446,7 +446,7 @@ B2B 알림 상품 출시 시(Phase 2) 업체에 대한 정보 제공 범위가
### 8-5. 개인정보 처리 위탁
Re:Link가 개인정보 처리를 위탁하는 수탁자와 위탁 업무 범위는 개인정보 처리방침에 공개한다.
Startover가 개인정보 처리를 위탁하는 수탁자와 위탁 업무 범위는 개인정보 처리방침에 공개한다.
| 수탁자 | 위탁 업무 |
|--------|----------|
@@ -497,4 +497,4 @@ Re:Link가 개인정보 처리를 위탁하는 수탁자와 위탁 업무 범위
|------|------|----------|--------|
| v1.0.0 | 2026-03-07 | 최초 작성 | 대표 + CTO |
> 이 문서는 Re:Link 운영 및 개발의 기준 정책 문서입니다. 변경 시 DRI(대표)의 승인을 거쳐야 하며, 변경 이력을 반드시 기록해야 합니다.
> 이 문서는 Startover 운영 및 개발의 기준 정책 문서입니다. 변경 시 DRI(대표)의 승인을 거쳐야 하며, 변경 이력을 반드시 기록해야 합니다.
+6 -6
View File
@@ -1,4 +1,4 @@
# Re:Link 지원금 대행 정책서
# Startover 지원금 대행 정책서
> 문서 코드: D001
> 버전: 1.0.0
@@ -26,7 +26,7 @@
### 1-1. 포지션 원칙
Re:Link의 지원금 서비스는 **정부 사업의 경쟁자가 아닌 신청 보조 파트너**다.
Startover의 지원금 서비스는 **정부 사업의 경쟁자가 아닌 신청 보조 파트너**다.
플랫폼은 폐업자가 정부 지원금을 받기 위해 스스로 준비해야 할 서류와 절차를 안내하고, 운영자가 서류 구비 상태를 검토하여 제출 준비 완료 여부를 판정하는 역할까지만 담당한다. 정부 기관을 대신하여 자동으로 서류를 제출하거나, 심사 결과에 영향을 주는 행위는 MVP에서 하지 않는다.
@@ -498,15 +498,15 @@ NOT_ELIGIBLE 판별 시: ELIGIBILITY_CHECKED 상태에서 케이스를 CLOSED
**[이용자 안내 및 면책 고지]**
Re:Link 지원금 서비스는 소상공인의 정부 지원금 신청 절차를 안내하는 보조 서비스입니다.
Startover 지원금 서비스는 소상공인의 정부 지원금 신청 절차를 안내하는 보조 서비스입니다.
1. Re:Link는 정부 기관(소상공인시장진흥공단 등)을 대리하지 않으며, 지원금 심사 및 승인 결과에 영향을 주지 않습니다.
1. Startover는 정부 기관(소상공인시장진흥공단 등)을 대리하지 않으며, 지원금 심사 및 승인 결과에 영향을 주지 않습니다.
2. 플랫폼이 제공하는 자격 판별 결과는 입력 정보 기반의 안내용이며, 법적 효력이 없습니다.
3. 최종 지원금 심사 및 승인 여부는 소상공인시장진흥공단의 결정에 따릅니다.
4. Re:Link는 지원금 수령을 보장하거나 확약하지 않습니다.
4. Startover는 지원금 수령을 보장하거나 확약하지 않습니다.
5. 제출 서류의 진실성과 정확성에 대한 책임은 신청자 본인에게 있습니다.
6. 허위 서류 제출 또는 부정 수급 시 관련 법령에 따라 환수 및 제재를 받을 수 있습니다.
7. Re:Link는 세무·법률 전문가의 의견을 대체하는 자문을 제공하지 않습니다.
7. Startover는 세무·법률 전문가의 의견을 대체하는 자문을 제공하지 않습니다.
### 8-2. 정부 승인 불보장 원칙
+3 -3
View File
@@ -8,9 +8,9 @@
"test": "turbo run test",
"type-check": "turbo run type-check",
"clean": "turbo run clean && rm -rf node_modules",
"db:generate": "pnpm --filter @relink/database prisma generate",
"db:push": "pnpm --filter @relink/database prisma db push",
"db:migrate": "pnpm --filter @relink/database prisma migrate dev",
"db:generate": "pnpm --filter @startover/database prisma generate",
"db:push": "pnpm --filter @startover/database prisma db push",
"db:migrate": "pnpm --filter @startover/database prisma migrate dev",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\""
},
"devDependencies": {
+2 -2
View File
@@ -1,5 +1,5 @@
{
"name": "@relink/analytics",
"name": "@startover/analytics",
"version": "0.0.1",
"private": false,
"type": "module",
@@ -19,7 +19,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@relink/shared": "workspace:*"
"@startover/shared": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @relink/analytics - Event schemas and KPI calculations
* @startover/analytics - Event schemas and KPI calculations
*/
export type { AnalyticsEvent } from './event.js';
+1 -1
View File
@@ -6,5 +6,5 @@ export default defineConfig({
dts: true,
clean: true,
sourcemap: true,
external: ['@relink/shared'],
external: ['@startover/shared'],
});
+3 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@relink/application",
"name": "@startover/application",
"version": "0.0.1",
"private": false,
"type": "module",
@@ -19,8 +19,8 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@relink/domain": "workspace:*",
"@relink/shared": "workspace:*"
"@startover/domain": "workspace:*",
"@startover/shared": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @relink/application - Use cases and application services
* @startover/application - Use cases and application services
*
* Orchestrates domain entities and defines application-level business rules.
*/
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Result } from '@relink/shared';
import type { Result } from '@startover/shared';
/**
* Base use case interface. All application use cases implement this.
+1 -1
View File
@@ -6,5 +6,5 @@ export default defineConfig({
dts: true,
clean: true,
sourcemap: true,
external: ['@relink/domain', '@relink/shared'],
external: ['@startover/domain', '@startover/shared'],
});
+1 -1
View File
@@ -1 +1 @@
DATABASE_URL="postgresql://relink:relink_dev@localhost:5432/relink_dev"
DATABASE_URL="postgresql://startover:startover_dev@localhost:5432/startover_dev"
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@relink/database",
"name": "@startover/database",
"version": "0.0.1",
"private": false,
"type": "module",
+1 -1
View File
@@ -1,5 +1,5 @@
// =============================================================================
// Re:Link MVP - Prisma Schema
// Startover MVP - Prisma Schema
// =============================================================================
// 설계 원칙:
// - 내부 PK: BigInt @id @default(autoincrement())
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @relink/database - Prisma client and database utilities
* @startover/database - Prisma client and database utilities
*/
export { createPrismaClient } from './client.js';
+6 -3
View File
@@ -8,7 +8,7 @@ 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';
'postgresql://startover:startover_test@localhost:5433/startover_test';
let testClient: PrismaClient | null = null;
@@ -26,10 +26,13 @@ export function getTestPrismaClient(): PrismaClient {
}
export async function setupTestDatabase(): Promise<PrismaClient> {
execSync(`DATABASE_URL="${TEST_DATABASE_URL}" npx prisma db push --schema="${SCHEMA_PATH}" --skip-generate --accept-data-loss`, {
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();
+2 -2
View File
@@ -1,5 +1,5 @@
{
"name": "@relink/domain",
"name": "@startover/domain",
"version": "0.0.1",
"private": false,
"type": "module",
@@ -19,7 +19,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@relink/shared": "workspace:*"
"@startover/shared": "workspace:*"
},
"devDependencies": {
"tsup": "^8.3.5",
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export interface CheckIdempotencyInput {
readonly idempotencyKey: string;
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export type ContractType = 'ACQUISITION' | 'DEMOLITION' | 'INTERIOR';
@@ -16,9 +16,7 @@ export interface ContractDraft {
readonly policyVersionId: string;
}
export function createContract(
input: CreateContractInput,
): Result<ContractDraft, AppError> {
export function createContract(input: CreateContractInput): Result<ContractDraft, AppError> {
// U021: 수락된 매칭만 계약 생성 가능
if (input.matchRequestStatus !== 'ACCEPTED') {
return failure(
+8 -6
View File
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export interface OpenDisputeInput {
readonly currentEscrowStatus: string;
@@ -17,9 +17,7 @@ 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> {
export function openDispute(input: OpenDisputeInput): Result<OpenDisputeResult, AppError> {
if (!DISPUTABLE_CONTRACT_STATUSES.has(input.contractStatus)) {
return failure(
appError('INVALID_CONTRACT_STATUS', '활성 상태의 계약만 분쟁을 열 수 있습니다.', {
@@ -30,9 +28,13 @@ export function openDispute(
if (!DISPUTABLE_ESCROW_STATUSES.has(input.currentEscrowStatus)) {
return failure(
appError('INVALID_ESCROW_STATUS', 'HOLDING 또는 RELEASE_REVIEW 상태에서만 분쟁을 열 수 있습니다.', {
appError(
'INVALID_ESCROW_STATUS',
'HOLDING 또는 RELEASE_REVIEW 상태에서만 분쟁을 열 수 있습니다.',
{
currentEscrowStatus: input.currentEscrowStatus,
}),
},
),
);
}
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export interface ReleaseEscrowInput {
readonly currentEscrowStatus: string;
@@ -10,9 +10,7 @@ export interface ReleaseEscrowResult {
readonly escrowStatus: 'RELEASE_REVIEW';
}
export function releaseEscrow(
input: ReleaseEscrowInput,
): Result<ReleaseEscrowResult, AppError> {
export function releaseEscrow(input: ReleaseEscrowInput): Result<ReleaseEscrowResult, AppError> {
// U023: HOLDING 상태에서만 정산 해제 가능
if (input.currentEscrowStatus !== 'HOLDING') {
return failure(
@@ -31,9 +29,7 @@ export function releaseEscrow(
// U026: 분쟁이 열려있으면 정산 해제 차단
if (input.hasOpenDispute) {
return failure(
appError('DISPUTE_OPEN', '열린 분쟁이 있어 정산 해제가 차단됩니다.'),
);
return failure(appError('DISPUTE_OPEN', '열린 분쟁이 있어 정산 해제가 차단됩니다.'));
}
return success({
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @relink/domain - Pure TypeScript domain layer
* @startover/domain - Pure TypeScript domain layer
*
* Contains entities, value objects, and business rules.
* No external dependencies allowed.
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export type MatchRequestStatus =
| 'OPEN'
@@ -26,9 +26,13 @@ export function acceptMatchRequest(
if (!acceptableStatuses.includes(input.currentStatus)) {
return failure(
appError('INVALID_STATUS_TRANSITION', 'OPEN 또는 REVIEWING 상태의 요청만 수락할 수 있습니다.', {
appError(
'INVALID_STATUS_TRANSITION',
'OPEN 또는 REVIEWING 상태의 요청만 수락할 수 있습니다.',
{
currentStatus: input.currentStatus,
}),
},
),
);
}
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export type MatchType = 'ACQUISITION' | 'DEMOLITION' | 'INTERIOR';
export type MatchSourceType = 'USER_REQUEST' | 'OPERATOR_RECOMMENDATION' | 'SYSTEM_MATCH';
@@ -1,6 +1,10 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
import { StoreLease, type StoreLeaseInput, type StoreLeaseProps } from './store-lease.js';
import { StoreFacility, type StoreFacilityInput, type StoreFacilityProps } from './store-facility.js';
import {
StoreFacility,
type StoreFacilityInput,
type StoreFacilityProps,
} from './store-facility.js';
// ---------------------------------------------------------------------------
// Dependency interfaces (DIP)
@@ -92,9 +96,7 @@ export function createStoreDraft(
if (fieldErrors.length === 1) {
const err = fieldErrors[0]!;
return failure(
appError('VALIDATION_ERROR', err.message, { field: err.field }),
);
return failure(appError('VALIDATION_ERROR', err.message, { field: err.field }));
}
if (fieldErrors.length > 1) {
+2 -4
View File
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export interface PublishStoreInput {
readonly currentReviewStatus: string;
@@ -11,9 +11,7 @@ export interface PublishStoreResult {
readonly policyVersionId: string;
}
export function publishStore(
input: PublishStoreInput,
): Result<PublishStoreResult, AppError> {
export function publishStore(input: PublishStoreInput): Result<PublishStoreResult, AppError> {
if (input.currentReviewStatus !== 'APPROVED') {
return failure(
appError('NOT_APPROVED', '승인된 매장만 공개할 수 있습니다.', {
+3 -7
View File
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export type ReviewDecision = 'APPROVED' | 'REJECTED';
export type ReviewableStatus = 'DRAFT' | 'SUBMITTED' | 'REVIEWING' | 'APPROVED' | 'REJECTED';
@@ -17,9 +17,7 @@ export interface ReviewStoreResult {
readonly memo?: string;
}
export function reviewStore(
input: ReviewStoreInput,
): Result<ReviewStoreResult, AppError> {
export function reviewStore(input: ReviewStoreInput): Result<ReviewStoreResult, AppError> {
if (input.currentReviewStatus !== 'SUBMITTED') {
return failure(
appError('INVALID_STATUS_TRANSITION', 'SUBMITTED 상태의 매장만 검토할 수 있습니다.', {
@@ -35,9 +33,7 @@ export function reviewStore(
);
}
if (!input.memo?.trim()) {
return failure(
appError('VALIDATION_ERROR', '반려 시 메모는 필수입니다.', { field: 'memo' }),
);
return failure(appError('VALIDATION_ERROR', '반려 시 메모는 필수입니다.', { field: 'memo' }));
}
}
+4 -2
View File
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export interface StoreFacilityInput {
readonly exclusiveAreaSqm: number;
@@ -28,7 +28,9 @@ export const StoreFacility = {
create(input: StoreFacilityInput): Result<StoreFacilityProps, AppError> {
if (input.exclusiveAreaSqm <= 0) {
return failure(
appError('VALIDATION_ERROR', '전용면적은 0보다 커야 합니다.', { field: 'exclusiveAreaSqm' }),
appError('VALIDATION_ERROR', '전용면적은 0보다 커야 합니다.', {
field: 'exclusiveAreaSqm',
}),
);
}
+4 -2
View File
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export interface StoreLeaseInput {
readonly depositAmount: number;
@@ -42,7 +42,9 @@ export const StoreLease = {
if (input.maintenanceFeeAmount !== undefined && input.maintenanceFeeAmount < 0) {
return failure(
appError('VALIDATION_ERROR', '관리비는 0 이상이어야 합니다.', { field: 'maintenanceFeeAmount' }),
appError('VALIDATION_ERROR', '관리비는 0 이상이어야 합니다.', {
field: 'maintenanceFeeAmount',
}),
);
}
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export type ChecklistItemStatus = 'PENDING' | 'CHECKED' | 'NOT_APPLICABLE';
@@ -25,9 +25,13 @@ export function advanceSubsidyToReady(
// DOCUMENTS_PENDING 상태에서만 READY_TO_SUBMIT으로 전이 가능
if (input.currentStatus !== 'DOCUMENTS_PENDING') {
return failure(
appError('INVALID_STATUS_TRANSITION', 'DOCUMENTS_PENDING 상태에서만 제출 준비로 전환할 수 있습니다.', {
appError(
'INVALID_STATUS_TRANSITION',
'DOCUMENTS_PENDING 상태에서만 제출 준비로 전환할 수 있습니다.',
{
currentStatus: input.currentStatus,
}),
},
),
);
}
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export type SubsidyCaseStatus =
| 'DRAFT'
@@ -45,18 +45,20 @@ export function createSubsidyCase(
if (!isPublished && !isReviewable) {
return failure(
appError('STORE_NOT_ELIGIBLE', '공개되었거나 검토 가능한 매장만 지원금 케이스를 시작할 수 있습니다.', {
appError(
'STORE_NOT_ELIGIBLE',
'공개되었거나 검토 가능한 매장만 지원금 케이스를 시작할 수 있습니다.',
{
reviewStatus: input.storeReviewStatus,
publicationStatus: input.storePublicationStatus,
}),
},
),
);
}
// U017: 체크리스트 항목이 없으면 케이스를 생성할 수 없다
if (input.checklistItems.length === 0) {
return failure(
appError('EMPTY_CHECKLIST', '체크리스트 항목이 최소 1개 이상 있어야 합니다.'),
);
return failure(appError('EMPTY_CHECKLIST', '체크리스트 항목이 최소 1개 이상 있어야 합니다.'));
}
return success({
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export type SubsidyReviewDecision = 'APPROVED' | 'REJECTED';
@@ -22,9 +22,13 @@ export function reviewSubsidyCase(
): Result<ReviewSubsidyCaseResult, AppError> {
if (!REVIEWABLE_STATUSES.has(input.currentStatus)) {
return failure(
appError('INVALID_STATUS_TRANSITION', 'SUBMITTED 또는 REVIEWING 상태의 케이스만 검토할 수 있습니다.', {
appError(
'INVALID_STATUS_TRANSITION',
'SUBMITTED 또는 REVIEWING 상태의 케이스만 검토할 수 있습니다.',
{
currentStatus: input.currentStatus,
}),
},
),
);
}
+3 -7
View File
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
export type VendorCertificationStatus =
| 'APPLIED'
@@ -29,9 +29,7 @@ export function applyVendorCertification(
input: ApplyVendorCertificationInput,
): Result<VendorCertificationApplication, AppError> {
if (!input.businessName.trim()) {
return failure(
appError('VALIDATION_ERROR', '업체명은 필수입니다.', { field: 'businessName' }),
);
return failure(appError('VALIDATION_ERROR', '업체명은 필수입니다.', { field: 'businessName' }));
}
if (!input.contactName.trim()) {
@@ -41,9 +39,7 @@ export function applyVendorCertification(
}
if (!input.hasBusinessRegistration) {
return failure(
appError('MISSING_BUSINESS_REGISTRATION', '사업자등록증이 필요합니다.'),
);
return failure(appError('MISSING_BUSINESS_REGISTRATION', '사업자등록증이 필요합니다.'));
}
if (input.coverageRegionCount === 0) {
+11 -4
View File
@@ -1,4 +1,4 @@
import { success, failure, appError, type Result, type AppError } from '@relink/shared';
import { success, failure, appError, type Result, type AppError } from '@startover/shared';
import type { VendorCertificationStatus } from './apply-vendor-certification.js';
@@ -24,9 +24,13 @@ export function approveVendorCertification(
): Result<ApproveVendorCertificationResult, AppError> {
if (!REVIEWABLE_STATUSES.has(input.currentStatus)) {
return failure(
appError('INVALID_STATUS_TRANSITION', 'APPLIED 또는 REVIEWING 상태의 인증만 검토할 수 있습니다.', {
appError(
'INVALID_STATUS_TRANSITION',
'APPLIED 또는 REVIEWING 상태의 인증만 검토할 수 있습니다.',
{
currentStatus: input.currentStatus,
}),
},
),
);
}
@@ -41,7 +45,10 @@ export function approveVendorCertification(
// U018: 서비스 권역이 없으면 승인 불가
if (input.coverageRegionCount === 0) {
return failure(
appError('MISSING_COVERAGE_REGION', '서비스 권역이 최소 1개 이상 있어야 승인할 수 있습니다.'),
appError(
'MISSING_COVERAGE_REGION',
'서비스 권역이 최소 1개 이상 있어야 승인할 수 있습니다.',
),
);
}
}
+5 -5
View File
@@ -1,5 +1,5 @@
{
"name": "@relink/infrastructure",
"name": "@startover/infrastructure",
"version": "0.0.1",
"private": false,
"type": "module",
@@ -20,10 +20,10 @@
},
"dependencies": {
"@prisma/client": "^6.1.0",
"@relink/application": "workspace:*",
"@relink/database": "workspace:*",
"@relink/domain": "workspace:*",
"@relink/shared": "workspace:*"
"@startover/application": "workspace:*",
"@startover/database": "workspace:*",
"@startover/domain": "workspace:*",
"@startover/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.10.2",
+1 -1
View File
@@ -6,5 +6,5 @@ export default defineConfig({
dts: true,
clean: true,
sourcemap: true,
external: ['@relink/application', '@relink/domain', '@relink/shared'],
external: ['@startover/application', '@startover/domain', '@startover/shared'],
});
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@relink/shared",
"name": "@startover/shared",
"version": "0.0.1",
"private": false,
"type": "module",
+1 -1
View File
@@ -1,4 +1,4 @@
export const APP_NAME = 'Re:Link' as const;
export const APP_NAME = 'Startover' as const;
export const PAGINATION = {
DEFAULT_PAGE: 1,
+1 -1
View File
@@ -25,7 +25,7 @@ export function expectFailure<T, E = AppError>(result: Result<T, E>): E {
* 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';
const base = baseUrl ?? 'postgresql://startover:startover_test@localhost:5433/startover_test';
return base;
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@relink/ui",
"name": "@startover/ui",
"version": "0.0.1",
"private": false,
"type": "module",
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @relink/ui - Shared UI components
* @startover/ui - Shared UI components
*/
export { Button } from './button.js';
+2 -2
View File
@@ -1,8 +1,8 @@
# 프로젝트: Re:Link MVP 구현 plan
# 프로젝트: Startover MVP 구현 plan
## 개요
Re:Link MVP의 목표는 폐업자 매장 정보를 기반으로 창업자, 철거업체, 인테리어업체를 연결하는 `assisted marketplace`를 베타 수준으로 출시하는 것이다.
Startover MVP의 목표는 폐업자 매장 정보를 기반으로 창업자, 철거업체, 인테리어업체를 연결하는 `assisted marketplace`를 베타 수준으로 출시하는 것이다.
이 plan은 `DEVELOPMENT_PLAN.md`를 실제 구현 단위로 쪼갠 작업 문서다. 정책 문서, 구조 작업, 테스트 시나리오, 에러 시나리오, 우선순위, 진행 상태를 한 곳에서 관리한다.

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