Files
chpark df7857a8ef
Deploy Startover / deploy (push) Failing after 0s
feat: 블로그·정보 페이지 추가 및 매매정보 도메인 확장
AdSense 승인을 위한 콘텐츠 인프라와 점포라인형 매매 정보 모델을 도입.

- 업종 분류 확장: 7개 대분류(휴게음식점/일반음식점/주류점/오락스포츠/판매업/서비스업/기타업종) 하위 소분류
- StoreSale 모델 추가: 월매출·월수익·창업비용·매물설명·입지특징·매매사유
- 매장 검색 카드 재설계(대표 사진 + 권리금 + 월수익), 등록/상세 페이지 매매정보 섹션
- 블로그 시스템: 17개 포스트(폐업/창업/지원금/인테리어), /blog, /blog/[slug]
- 정보 페이지: /about, /terms, /privacy, /faq, /contact
- SEO: sitemap.ts, robots.ts, 페이지별 메타데이터, Article·FAQ JSON-LD, OG 태그
- 주소 라벨 도로명 주소 → 주소

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:52:21 +09:00

1219 lines
48 KiB
Plaintext

// =============================================================================
// Startover MVP - Prisma Schema
// =============================================================================
// 설계 원칙:
// - 내부 PK: BigInt @id @default(autoincrement())
// - 외부 노출 ID: publicId String @unique @default(cuid())
// - 금액: Decimal @db.Decimal(14, 2)
// - 시각: DateTime @db.Timestamptz(6)
// - Prisma 모델명: PascalCase / 필드명: camelCase
// - DB 테이블/컬럼: snake_case (@@map / @map)
// - append-only 모델: updatedAt 없이 createdAt만
// - soft delete 필요 모델만 deletedAt
//
// Prisma 미지원 제약 (custom SQL migration에서 추가):
// 1. partial unique index (열린 매칭 요청 중복 방지 등)
// 2. CHECK constraints (amount >= 0, exclusiveAreaSqm > 0 등)
// 3. PostGIS GIST index (향후 locationPoint)
// =============================================================================
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = []
}
// =============================================================================
// Enums
// =============================================================================
enum UserRole {
CLOSING_OWNER
FOUNDER
VENDOR_MANAGER
OPS_MANAGER
SUBSIDY_OPERATOR
TRUST_OPERATOR
FINANCE_OPERATOR
SUPER_ADMIN
@@map("user_role")
}
enum UserStatus {
PENDING_VERIFICATION
ACTIVE
SUSPENDED
DEACTIVATED
@@map("user_status")
}
enum ProfileType {
CLOSING_OWNER
FOUNDER
VENDOR_MANAGER
OPERATOR
@@map("profile_type")
}
enum ConsentType {
TERMS_OF_SERVICE
PRIVACY_POLICY_REQUIRED
PRIVACY_POLICY_MARKETING
STORE_PUBLICATION_CONSENT
MATCHED_INFO_DISCLOSURE
THIRD_PARTY_MATCHED_PARTY
NOTIFICATION_KAKAO
@@map("consent_type")
}
enum StoreReviewStatus {
DRAFT
SUBMITTED
REVIEWING
APPROVED
REJECTED
@@map("store_review_status")
}
enum StorePublicationStatus {
PRIVATE
RESTRICTED
PUBLISHED
UNPUBLISHED
@@map("store_publication_status")
}
enum StoreDealStatus {
OPEN
MATCHING
RESERVED
CONTRACTED
CLOSED
CANCELLED
@@map("store_deal_status")
}
enum PhotoCategory {
EXTERIOR
INTERIOR
KITCHEN
EQUIPMENT
FLOOR_PLAN
DOCUMENT
@@map("photo_category")
}
enum VisibilityScope {
INTERNAL
MATCHED_ONLY
PUBLIC_SUMMARY
@@map("visibility_scope")
}
enum MatchType {
ACQUISITION
DEMOLITION
INTERIOR
@@map("match_type")
}
enum MatchRequestStatus {
OPEN
REVIEWING
ACCEPTED
REJECTED
CONTRACTING
EXPIRED
CANCELLED
COMPLETED
@@map("match_request_status")
}
enum MatchSourceType {
USER_REQUEST
OPERATOR_RECOMMENDATION
SYSTEM_MATCH
@@map("match_source_type")
}
enum SubsidyCaseStatus {
DRAFT
ELIGIBILITY_CHECKED
DOCUMENTS_PENDING
REVIEWING
READY_TO_SUBMIT
SUBMITTED
APPROVED
REJECTED
CLOSED
@@map("subsidy_case_status")
}
enum EligibilityResult {
UNKNOWN
ELIGIBLE
CONDITIONALLY_ELIGIBLE
NOT_ELIGIBLE
@@map("eligibility_result")
}
enum ChecklistItemStatus {
PENDING
CHECKED
NOT_APPLICABLE
@@map("checklist_item_status")
}
enum DocumentReviewStatus {
PENDING
APPROVED
REJECTED
RESUBMIT_REQUIRED
@@map("document_review_status")
}
enum VendorType {
DEMOLITION
INTERIOR
BOTH
@@map("vendor_type")
}
enum VendorCertificationStatus {
APPLIED
REVIEWING
APPROVED
REJECTED
SUSPENDED
EXPIRED
@@map("vendor_certification_status")
}
enum ContractType {
ACQUISITION
DEMOLITION
INTERIOR
@@map("contract_type")
}
enum ContractStatus {
DRAFT
GENERATED
SIGNING
SIGNED
ACTIVE
COMPLETED
CANCELLED
TERMINATED
@@map("contract_status")
}
enum EscrowStatus {
NOT_STARTED
DEPOSIT_PENDING
HOLDING
RELEASE_REVIEW
RELEASED
REFUNDED
DISPUTED
@@map("escrow_status")
}
enum EscrowTransactionType {
DEPOSIT
RELEASE
REFUND
ADJUSTMENT
HOLD
@@map("escrow_transaction_type")
}
enum EscrowTransactionStatus {
PENDING
PROCESSING
COMPLETED
FAILED
CANCELLED
@@map("escrow_transaction_status")
}
enum SignerRole {
STORE_OWNER
BUYER
VENDOR
OPERATOR
WITNESS
@@map("signer_role")
}
enum EvidenceType {
ELECTRONIC_SIGNATURE
SMS_OTP
APP_CONSENT
MANUAL_UPLOAD
@@map("evidence_type")
}
enum InspectionType {
PRE_CONTRACT
MID_WORK
FINAL_COMPLETION
@@map("inspection_type")
}
enum InspectionStatus {
REQUESTED
SUBMITTED
REVIEWING
APPROVED
REJECTED
@@map("inspection_status")
}
enum DisputeStatus {
OPEN
INVESTIGATING
MEDIATING
RESOLVED
CLOSED
@@map("dispute_status")
}
enum PolicyType {
PRIVACY_POLICY
TERMS_OF_SERVICE
STORE_LISTING_POLICY
CONTRACT_TEMPLATE
SUBSIDY_CHECKLIST
@@map("policy_type")
}
enum PublishStatus {
PENDING
PUBLISHED
FAILED
@@map("publish_status")
}
enum RegionType {
COUNTRY
SIDO
SIGUNGU
ADMIN_DONG
COMMERCIAL_AREA
BETA_CLUSTER
@@map("region_type")
}
enum UrgencyLevel {
LOW
MEDIUM
HIGH
CRITICAL
@@map("urgency_level")
}
enum RestroomType {
PRIVATE
SHARED
NONE
@@map("restroom_type")
}
enum PiiLevel {
NONE
LOW
MEDIUM
HIGH
@@map("pii_level")
}
// =============================================================================
// 1. 사용자/권한
// =============================================================================
/// 사용자 기본 계정과 로그인 주체
model User {
id BigInt @id @default(autoincrement()) @map("id")
publicId String @unique @default(cuid()) @map("public_id")
email String @map("email")
emailNormalized String @unique @map("email_normalized")
phone String? @map("phone")
phoneNormalized String? @map("phone_normalized")
name String @map("name")
passwordHash String? @map("password_hash")
image String? @map("image")
primaryRole UserRole @map("primary_role")
status UserStatus @default(PENDING_VERIFICATION) @map("status")
emailVerifiedAt DateTime? @map("email_verified_at") @db.Timestamptz(6)
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
profiles UserProfile[]
consents UserConsent[]
stores Store[]
vendors Vendor[]
matchRequestsAsRequester MatchRequest[] @relation("MatchRequesterUser")
matchRequestsAsOperator MatchRequest[] @relation("MatchOperatorAssignee")
subsidyCasesAsApplicant SubsidyCase[] @relation("SubsidyApplicantUser")
subsidyCasesAsOperator SubsidyCase[] @relation("SubsidyOperatorAssignee")
subsidyChecklistCheckedBy SubsidyChecklistItem[] @relation("ChecklistCheckedByUser")
subsidyDocsUploadedBy SubsidyDocument[] @relation("SubsidyDocUploadedByUser")
subsidyDocsReviewedBy SubsidyDocument[] @relation("SubsidyDocReviewedByUser")
storePhotosUploadedBy StorePhoto[]
contractsCreatedBy Contract[] @relation("ContractCreatedByUser")
contractsAsStoreOwner Contract[] @relation("ContractStoreOwnerUser")
contractsAsBuyer Contract[] @relation("ContractBuyerUser")
contractVersionsCreatedBy ContractVersion[]
signaturesAsSigner SignatureEvidence[]
escrowRequested EscrowTransaction[] @relation("EscrowRequestedByUser")
escrowApproved EscrowTransaction[] @relation("EscrowApprovedByUser")
inspectionsSubmittedBy InspectionRecord[] @relation("InspectionSubmittedByUser")
inspectionsReviewedBy InspectionRecord[] @relation("InspectionReviewedByUser")
disputesOpenedBy DisputeCase[] @relation("DisputeOpenedByUser")
disputesAssignedOperator DisputeCase[] @relation("DisputeAssignedOperator")
disputesResolvedBy DisputeCase[] @relation("DisputeResolvedByUser")
certificationReviewedBy VendorCertification[] @relation("CertificationReviewedByUser")
auditLogs AuditLog[]
accounts Account[]
invitesCreated InviteToken[] @relation("InviteCreator")
@@index([primaryRole, status], map: "idx_user_role_status")
@@map("users")
}
/// 역할별 추가 프로필 정보 확장 테이블
model UserProfile {
id BigInt @id @default(autoincrement()) @map("id")
userId BigInt @map("user_id")
profileType ProfileType @map("profile_type")
businessRegistrationNumber String? @map("business_registration_number")
metadataJson Json? @map("metadata_json")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
user User @relation(fields: [userId], references: [id])
@@index([userId], map: "idx_user_profile_user_id")
@@map("user_profiles")
}
/// 개인정보/마케팅/제3자 제공 동의를 버전 단위로 저장
model UserConsent {
id BigInt @id @default(autoincrement()) @map("id")
userId BigInt @map("user_id")
consentType ConsentType @map("consent_type")
policyVersionId BigInt @map("policy_version_id")
isGranted Boolean @map("is_granted")
grantedAt DateTime @map("granted_at") @db.Timestamptz(6)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6)
// Relations
user User @relation(fields: [userId], references: [id])
policyVersion PolicyVersion @relation(fields: [policyVersionId], references: [id])
@@index([userId, consentType], map: "idx_user_consent_user_type")
@@map("user_consents")
}
/// Auth.js 소셜 로그인 계정 연동
model Account {
id BigInt @id @default(autoincrement()) @map("id")
userId BigInt @map("user_id")
type String @map("type")
provider String @map("provider")
providerAccountId String @map("provider_account_id")
refresh_token String? @map("refresh_token") @db.Text
access_token String? @map("access_token") @db.Text
expires_at Int? @map("expires_at")
token_type String? @map("token_type")
scope String? @map("scope")
id_token String? @map("id_token") @db.Text
session_state String? @map("session_state")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
/// 이메일 인증 토큰
model VerificationToken {
identifier String @map("identifier")
token String @unique @map("token")
expires DateTime @map("expires") @db.Timestamptz(6)
@@id([identifier, token])
@@map("verification_tokens")
}
/// 운영자 초대 토큰
model InviteToken {
id BigInt @id @default(autoincrement()) @map("id")
email String @map("email")
role UserRole @map("role")
token String @unique @default(cuid()) @map("token")
expires DateTime @map("expires") @db.Timestamptz(6)
usedAt DateTime? @map("used_at") @db.Timestamptz(6)
createdBy BigInt @map("created_by")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
creator User @relation("InviteCreator", fields: [createdBy], references: [id])
@@map("invite_tokens")
}
// =============================================================================
// 2. 매장 공급 도메인
// =============================================================================
/// 폐업자가 등록한 매장의 중심 aggregate
model Store {
id BigInt @id @default(autoincrement()) @map("id")
publicId String @unique @default(cuid()) @map("public_id")
ownerUserId BigInt @map("owner_user_id")
listingTitle String @map("listing_title")
publicSummary String? @map("public_summary")
industryLeafId BigInt? @map("industry_leaf_id")
regionClusterId BigInt? @map("region_cluster_id")
regionLeafId BigInt? @map("region_leaf_id")
roadAddress String @map("road_address")
detailAddress String? @map("detail_address")
latitude Decimal? @map("latitude") @db.Decimal(10, 7)
longitude Decimal? @map("longitude") @db.Decimal(10, 7)
reviewStatus StoreReviewStatus @default(DRAFT) @map("review_status")
publicationStatus StorePublicationStatus @default(PRIVATE) @map("publication_status")
dealStatus StoreDealStatus @default(OPEN) @map("deal_status")
policyVersionId BigInt? @map("policy_version_id")
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
approvedAt DateTime? @map("approved_at") @db.Timestamptz(6)
rejectedAt DateTime? @map("rejected_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
ownerUser User @relation(fields: [ownerUserId], references: [id])
industryLeaf IndustryTaxonomy? @relation(fields: [industryLeafId], references: [id])
regionCluster RegionHierarchy? @relation("StoreRegionCluster", fields: [regionClusterId], references: [id])
regionLeaf RegionHierarchy? @relation("StoreRegionLeaf", fields: [regionLeafId], references: [id])
policyVersion PolicyVersion? @relation(fields: [policyVersionId], references: [id])
lease StoreLease?
sale StoreSale?
facility StoreFacility?
lifecycle StoreLifecycle?
photos StorePhoto[]
matchRequests MatchRequest[]
subsidyCases SubsidyCase[]
contracts Contract[]
@@index([ownerUserId, createdAt], map: "idx_store_owner_created")
@@index([reviewStatus, createdAt], map: "idx_store_review_created")
@@index([regionClusterId, industryLeafId, publicationStatus, dealStatus, publishedAt], map: "idx_store_search")
@@map("stores")
}
/// 임대/권리금 관련 정보 (Store 1:1 확장)
/// CHECK constraint: amount fields >= 0 (custom SQL migration)
model StoreLease {
id BigInt @id @default(autoincrement()) @map("id")
storeId BigInt @unique @map("store_id")
depositAmount Decimal? @map("deposit_amount") @db.Decimal(14, 2)
monthlyRentAmount Decimal? @map("monthly_rent_amount") @db.Decimal(14, 2)
maintenanceFeeAmount Decimal? @map("maintenance_fee_amount") @db.Decimal(14, 2)
premiumAmount Decimal? @map("premium_amount") @db.Decimal(14, 2)
transferable Boolean? @map("transferable")
leaseExpiresAt DateTime? @map("lease_expires_at") @db.Timestamptz(6)
remainingLeaseMonths Int? @map("remaining_lease_months")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
store Store @relation(fields: [storeId], references: [id])
@@map("store_leases")
}
/// 매매 정보 (권리금/월매출/월수익/창업비용/매물설명 등) (Store 1:1 확장)
model StoreSale {
id BigInt @id @default(autoincrement()) @map("id")
storeId BigInt @unique @map("store_id")
premiumAmount Decimal? @map("premium_amount") @db.Decimal(14, 2)
monthlySalesAmount Decimal? @map("monthly_sales_amount") @db.Decimal(14, 2)
monthlyProfitAmount Decimal? @map("monthly_profit_amount") @db.Decimal(14, 2)
startupCostAmount Decimal? @map("startup_cost_amount") @db.Decimal(14, 2)
listingDescription String? @map("listing_description")
locationHighlight String? @map("location_highlight")
saleReason String? @map("sale_reason")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
store Store @relation(fields: [storeId], references: [id])
@@map("store_sales")
}
/// F&B 필터링과 인수 판단에 필요한 설비 정보 (Store 1:1 확장)
/// CHECK constraint: exclusiveAreaSqm > 0 (custom SQL migration)
model StoreFacility {
id BigInt @id @default(autoincrement()) @map("id")
storeId BigInt @unique @map("store_id")
exclusiveAreaSqm Decimal? @map("exclusive_area_sqm") @db.Decimal(8, 2)
floorLevel Int? @map("floor_level")
seatCount Int? @map("seat_count")
hasGas Boolean? @map("has_gas")
hasDrainage Boolean? @map("has_drainage")
hasDuct Boolean? @map("has_duct")
electricCapacityKw Decimal? @map("electric_capacity_kw") @db.Decimal(6, 1)
kitchenEquipmentSummary String? @map("kitchen_equipment_summary")
parkingCount Int? @map("parking_count")
restroomType RestroomType? @map("restroom_type")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
store Store @relation(fields: [storeId], references: [id])
@@map("store_facilities")
}
/// 폐업/인수/철거 일정 정보 (Store 1:1 확장)
model StoreLifecycle {
id BigInt @id @default(autoincrement()) @map("id")
storeId BigInt @unique @map("store_id")
closingPlannedAt DateTime? @map("closing_planned_at") @db.Timestamptz(6)
takeoverAvailableAt DateTime? @map("takeover_available_at") @db.Timestamptz(6)
demolitionPreferredAt DateTime? @map("demolition_preferred_at") @db.Timestamptz(6)
vacateByAt DateTime? @map("vacate_by_at") @db.Timestamptz(6)
isCurrentlyOperating Boolean? @map("is_currently_operating")
urgencyLevel UrgencyLevel? @map("urgency_level")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
store Store @relation(fields: [storeId], references: [id])
@@map("store_lifecycles")
}
/// 매장 사진과 공개 범위 (append-only)
model StorePhoto {
id BigInt @id @default(autoincrement()) @map("id")
storeId BigInt @map("store_id")
storageKey String @map("storage_key")
photoCategory PhotoCategory @map("photo_category")
visibilityScope VisibilityScope @default(INTERNAL) @map("visibility_scope")
sortOrder Int @default(0) @map("sort_order")
uploadedByUserId BigInt @map("uploaded_by_user_id")
checksumSha256 String? @map("checksum_sha256")
width Int? @map("width")
height Int? @map("height")
takenAt DateTime? @map("taken_at") @db.Timestamptz(6)
isRepresentative Boolean @default(false) @map("is_representative")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Relations
store Store @relation(fields: [storeId], references: [id])
uploadedByUser User @relation(fields: [uploadedByUserId], references: [id])
@@index([storeId, sortOrder], map: "idx_store_photo_store_sort")
@@map("store_photos")
}
// =============================================================================
// 3. 매칭 도메인
// =============================================================================
/// 창업자/업체/운영자 추천 기반 매칭 요청
/// matchType에 따라 필요한 FK가 달라지므로 raw SQL CHECK 제약 추가 (custom SQL migration)
/// partial unique index: 열린 상태에서 동일 사용자/동일 매장/동일 요청 타입 중복 금지 (custom SQL migration)
model MatchRequest {
id BigInt @id @default(autoincrement()) @map("id")
publicId String @unique @default(cuid()) @map("public_id")
storeId BigInt @map("store_id")
matchType MatchType @map("match_type")
requesterUserId BigInt? @map("requester_user_id")
requesterVendorId BigInt? @map("requester_vendor_id")
sourceType MatchSourceType @map("source_type")
status MatchRequestStatus @default(OPEN) @map("status")
operatorAssigneeId BigInt? @map("operator_assignee_id")
message String? @map("message")
decisionReasonCode String? @map("decision_reason_code")
acceptedAt DateTime? @map("accepted_at") @db.Timestamptz(6)
rejectedAt DateTime? @map("rejected_at") @db.Timestamptz(6)
closedAt DateTime? @map("closed_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
store Store @relation(fields: [storeId], references: [id])
requesterUser User? @relation("MatchRequesterUser", fields: [requesterUserId], references: [id])
requesterVendor Vendor? @relation(fields: [requesterVendorId], references: [id])
operatorAssignee User? @relation("MatchOperatorAssignee", fields: [operatorAssigneeId], references: [id])
contract Contract?
@@index([storeId, status, createdAt], map: "idx_match_store_status_created")
@@index([requesterUserId, status, createdAt], map: "idx_match_requester_status_created")
@@index([requesterVendorId, status, createdAt], map: "idx_match_vendor_status_created")
@@map("match_requests")
}
// =============================================================================
// 4. 지원금 도메인
// =============================================================================
/// 매장 기준 지원금 진행 건
/// partial unique index: 같은 storeId + programCode 조합의 active case는 1개 (custom SQL migration)
model SubsidyCase {
id BigInt @id @default(autoincrement()) @map("id")
publicId String @unique @default(cuid()) @map("public_id")
storeId BigInt @map("store_id")
applicantUserId BigInt @map("applicant_user_id")
programCode String @map("program_code")
status SubsidyCaseStatus @default(DRAFT) @map("status")
eligibilityResult EligibilityResult @default(UNKNOWN) @map("eligibility_result")
policyVersionId BigInt? @map("policy_version_id")
operatorAssigneeId BigInt? @map("operator_assignee_id")
checklistVersionCode String? @map("checklist_version_code")
submissionReadyAt DateTime? @map("submission_ready_at") @db.Timestamptz(6)
submittedAt DateTime? @map("submitted_at") @db.Timestamptz(6)
reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(6)
rejectionReasonCode String? @map("rejection_reason_code")
eligibilitySnapshotJson Json? @map("eligibility_snapshot_json")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
store Store @relation(fields: [storeId], references: [id])
applicantUser User @relation("SubsidyApplicantUser", fields: [applicantUserId], references: [id])
policyVersion PolicyVersion? @relation(fields: [policyVersionId], references: [id])
operatorAssignee User? @relation("SubsidyOperatorAssignee", fields: [operatorAssigneeId], references: [id])
checklistItems SubsidyChecklistItem[]
documents SubsidyDocument[]
@@index([storeId, status, createdAt], map: "idx_subsidy_store_status_created")
@@index([applicantUserId, status, createdAt], map: "idx_subsidy_applicant_status_created")
@@index([operatorAssigneeId, status, updatedAt], map: "idx_subsidy_operator_status_updated")
@@map("subsidy_cases")
}
/// 정책 버전에 따라 생성된 체크리스트 항목
model SubsidyChecklistItem {
id BigInt @id @default(autoincrement()) @map("id")
subsidyCaseId BigInt @map("subsidy_case_id")
itemCode String @map("item_code")
isRequired Boolean @default(true) @map("is_required")
status ChecklistItemStatus @default(PENDING) @map("status")
checkedAt DateTime? @map("checked_at") @db.Timestamptz(6)
checkedByUserId BigInt? @map("checked_by_user_id")
// Relations
subsidyCase SubsidyCase @relation(fields: [subsidyCaseId], references: [id])
checkedByUser User? @relation("ChecklistCheckedByUser", fields: [checkedByUserId], references: [id])
@@index([subsidyCaseId], map: "idx_subsidy_checklist_case")
@@map("subsidy_checklist_items")
}
/// 지원금 첨부 서류와 검토 상태
model SubsidyDocument {
id BigInt @id @default(autoincrement()) @map("id")
subsidyCaseId BigInt @map("subsidy_case_id")
documentTypeCode String @map("document_type_code")
storageKey String @map("storage_key")
reviewStatus DocumentReviewStatus @default(PENDING) @map("review_status")
uploadedByUserId BigInt @map("uploaded_by_user_id")
uploadedAt DateTime @default(now()) @map("uploaded_at") @db.Timestamptz(6)
reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(6)
reviewedByUserId BigInt? @map("reviewed_by_user_id")
// Relations
subsidyCase SubsidyCase @relation(fields: [subsidyCaseId], references: [id])
uploadedByUser User @relation("SubsidyDocUploadedByUser", fields: [uploadedByUserId], references: [id])
reviewedByUser User? @relation("SubsidyDocReviewedByUser", fields: [reviewedByUserId], references: [id])
@@index([subsidyCaseId], map: "idx_subsidy_doc_case")
@@map("subsidy_documents")
}
// =============================================================================
// 5. 업체 도메인
// =============================================================================
/// 철거/인테리어 업체의 기본 프로필
model Vendor {
id BigInt @id @default(autoincrement()) @map("id")
publicId String @unique @default(cuid()) @map("public_id")
ownerUserId BigInt @map("owner_user_id")
vendorType VendorType @map("vendor_type")
businessName String @map("business_name")
contactName String @map("contact_name")
contactPhoneNormalized String? @map("contact_phone_normalized")
businessRegistrationNumber String? @map("business_registration_number")
serviceIntro String? @map("service_intro")
primaryRegionId BigInt? @map("primary_region_id")
certificationStatus VendorCertificationStatus @default(APPLIED) @map("certification_status")
latestCertificationId BigInt? @map("latest_certification_id")
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
ownerUser User @relation(fields: [ownerUserId], references: [id])
primaryRegion RegionHierarchy? @relation(fields: [primaryRegionId], references: [id])
coverageRegions VendorCoverageRegion[]
certifications VendorCertification[]
matchRequests MatchRequest[]
contracts Contract[]
@@index([ownerUserId], map: "idx_vendor_owner")
@@index([vendorType, certificationStatus, createdAt], map: "idx_vendor_type_cert_created")
@@map("vendors")
}
/// 업체 서비스 가능 지역 (다대다 조인)
model VendorCoverageRegion {
id BigInt @id @default(autoincrement()) @map("id")
vendorId BigInt @map("vendor_id")
regionId BigInt @map("region_id")
isPrimary Boolean @default(false) @map("is_primary")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Relations
vendor Vendor @relation(fields: [vendorId], references: [id])
region RegionHierarchy @relation(fields: [regionId], references: [id])
@@unique([vendorId, regionId], map: "uq_vendor_coverage_region")
@@index([regionId, vendorId], map: "idx_vendor_coverage_region_region")
@@map("vendor_coverage_regions")
}
/// 업체 인증 이력
model VendorCertification {
id BigInt @id @default(autoincrement()) @map("id")
vendorId BigInt @map("vendor_id")
status VendorCertificationStatus @map("status")
requestedScopeCode String? @map("requested_scope_code")
documentChecklistJson Json? @map("document_checklist_json")
appliedAt DateTime @default(now()) @map("applied_at") @db.Timestamptz(6)
reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(6)
reviewedByUserId BigInt? @map("reviewed_by_user_id")
reasonCode String? @map("reason_code")
validUntil DateTime? @map("valid_until") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
vendor Vendor @relation(fields: [vendorId], references: [id])
reviewedByUser User? @relation("CertificationReviewedByUser", fields: [reviewedByUserId], references: [id])
@@index([vendorId, status], map: "idx_vendor_cert_vendor_status")
@@map("vendor_certifications")
}
// =============================================================================
// 6. 신뢰 인프라 도메인
// =============================================================================
/// 매칭에서 생성된 계약의 현재 상태와 참여자
model Contract {
id BigInt @id @default(autoincrement()) @map("id")
publicId String @unique @default(cuid()) @map("public_id")
matchRequestId BigInt @unique @map("match_request_id")
storeId BigInt @map("store_id")
contractType ContractType @map("contract_type")
status ContractStatus @default(DRAFT) @map("status")
createdByUserId BigInt @map("created_by_user_id")
storeOwnerUserId BigInt @map("store_owner_user_id")
buyerUserId BigInt? @map("buyer_user_id")
vendorId BigInt? @map("vendor_id")
policyVersionId BigInt? @map("policy_version_id")
templateCode String? @map("template_code")
currentVersionId BigInt? @map("current_version_id")
escrowStatus EscrowStatus @default(NOT_STARTED) @map("escrow_status")
signedAt DateTime? @map("signed_at") @db.Timestamptz(6)
effectiveAt DateTime? @map("effective_at") @db.Timestamptz(6)
completedAt DateTime? @map("completed_at") @db.Timestamptz(6)
cancelledAt DateTime? @map("cancelled_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
matchRequest MatchRequest @relation(fields: [matchRequestId], references: [id])
store Store @relation(fields: [storeId], references: [id])
createdByUser User @relation("ContractCreatedByUser", fields: [createdByUserId], references: [id])
storeOwnerUser User @relation("ContractStoreOwnerUser", fields: [storeOwnerUserId], references: [id])
buyerUser User? @relation("ContractBuyerUser", fields: [buyerUserId], references: [id])
vendor Vendor? @relation(fields: [vendorId], references: [id])
policyVersion PolicyVersion? @relation(fields: [policyVersionId], references: [id])
versions ContractVersion[]
signatures SignatureEvidence[]
escrowTransactions EscrowTransaction[]
inspections InspectionRecord[]
disputes DisputeCase[]
@@index([storeId, status, createdAt], map: "idx_contract_store_status_created")
@@index([vendorId, status, createdAt], map: "idx_contract_vendor_status_created")
@@index([buyerUserId, status, createdAt], map: "idx_contract_buyer_status_created")
@@map("contracts")
}
/// 계약 문서 불변 버전 (append-only)
model ContractVersion {
id BigInt @id @default(autoincrement()) @map("id")
contractId BigInt @map("contract_id")
versionNo Int @map("version_no")
templateCode String? @map("template_code")
templateVersion String? @map("template_version")
documentStorageKey String? @map("document_storage_key")
documentSha256 String? @map("document_sha256")
renderedSnapshotJson Json? @map("rendered_snapshot_json")
changeSummary String? @map("change_summary")
createdByUserId BigInt @map("created_by_user_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Relations
contract Contract @relation(fields: [contractId], references: [id])
createdByUser User @relation(fields: [createdByUserId], references: [id])
signatures SignatureEvidence[]
@@unique([contractId, versionNo], map: "uq_contract_version")
@@map("contract_versions")
}
/// 전자서명 또는 동의 증적 (append-only)
model SignatureEvidence {
id BigInt @id @default(autoincrement()) @map("id")
contractId BigInt @map("contract_id")
contractVersionId BigInt @map("contract_version_id")
signerRole SignerRole @map("signer_role")
signerUserId BigInt @map("signer_user_id")
evidenceType EvidenceType @map("evidence_type")
providerCode String? @map("provider_code")
providerEventId String? @unique @map("provider_event_id")
signedAt DateTime @map("signed_at") @db.Timestamptz(6)
signatureHash String? @map("signature_hash")
payloadJson Json? @map("payload_json")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Relations
contract Contract @relation(fields: [contractId], references: [id])
contractVersion ContractVersion @relation(fields: [contractVersionId], references: [id])
signerUser User @relation(fields: [signerUserId], references: [id])
@@index([contractVersionId, signedAt], map: "idx_signature_version_signed")
@@map("signature_evidences")
}
/// 결제/환불/정산/보류 관련 금전 이벤트 immutable ledger (append-only)
/// CHECK constraint: amount >= 0 (custom SQL migration)
model EscrowTransaction {
id BigInt @id @default(autoincrement()) @map("id")
contractId BigInt @map("contract_id")
transactionType EscrowTransactionType @map("transaction_type")
providerCode String? @map("provider_code")
providerTransactionId String? @unique @map("provider_transaction_id")
status EscrowTransactionStatus @default(PENDING) @map("status")
amount Decimal @map("amount") @db.Decimal(14, 2)
currencyCode String @default("KRW") @map("currency_code")
requestedByUserId BigInt? @map("requested_by_user_id")
approvedByUserId BigInt? @map("approved_by_user_id")
idempotencyKey String @unique @map("idempotency_key")
rawPayloadJson Json? @map("raw_payload_json")
occurredAt DateTime @map("occurred_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Relations
contract Contract @relation(fields: [contractId], references: [id])
requestedByUser User? @relation("EscrowRequestedByUser", fields: [requestedByUserId], references: [id])
approvedByUser User? @relation("EscrowApprovedByUser", fields: [approvedByUserId], references: [id])
@@index([contractId, occurredAt], map: "idx_escrow_contract_occurred")
@@index([status, occurredAt], map: "idx_escrow_status_occurred")
@@map("escrow_transactions")
}
/// 검수 요청과 검토 결과
model InspectionRecord {
id BigInt @id @default(autoincrement()) @map("id")
contractId BigInt @map("contract_id")
inspectionType InspectionType @map("inspection_type")
status InspectionStatus @default(REQUESTED) @map("status")
submittedByUserId BigInt? @map("submitted_by_user_id")
submittedAt DateTime? @map("submitted_at") @db.Timestamptz(6)
reviewedByUserId BigInt? @map("reviewed_by_user_id")
reviewedAt DateTime? @map("reviewed_at") @db.Timestamptz(6)
reviewMemo String? @map("review_memo")
evidencePayloadJson Json? @map("evidence_payload_json")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
contract Contract @relation(fields: [contractId], references: [id])
submittedByUser User? @relation("InspectionSubmittedByUser", fields: [submittedByUserId], references: [id])
reviewedByUser User? @relation("InspectionReviewedByUser", fields: [reviewedByUserId], references: [id])
@@index([contractId, status], map: "idx_inspection_contract_status")
@@map("inspection_records")
}
/// 분쟁 접수, 조사, 중재, 종료 상태
/// partial unique index: 계약당 active dispute는 1건 (custom SQL migration)
model DisputeCase {
id BigInt @id @default(autoincrement()) @map("id")
contractId BigInt @map("contract_id")
openedByUserId BigInt @map("opened_by_user_id")
assignedOperatorId BigInt? @map("assigned_operator_id")
status DisputeStatus @default(OPEN) @map("status")
reasonCode String @map("reason_code")
description String? @map("description")
resolutionCode String? @map("resolution_code")
resolutionSummary String? @map("resolution_summary")
evidencePayloadJson Json? @map("evidence_payload_json")
openedAt DateTime @default(now()) @map("opened_at") @db.Timestamptz(6)
resolvedAt DateTime? @map("resolved_at") @db.Timestamptz(6)
resolvedByUserId BigInt? @map("resolved_by_user_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
// Relations
contract Contract @relation(fields: [contractId], references: [id])
openedByUser User @relation("DisputeOpenedByUser", fields: [openedByUserId], references: [id])
assignedOperator User? @relation("DisputeAssignedOperator", fields: [assignedOperatorId], references: [id])
resolvedByUser User? @relation("DisputeResolvedByUser", fields: [resolvedByUserId], references: [id])
@@index([contractId, status, openedAt], map: "idx_dispute_contract_status_opened")
@@index([assignedOperatorId, status, openedAt], map: "idx_dispute_operator_status_opened")
@@map("dispute_cases")
}
// =============================================================================
// 7. 운영/분석 도메인
// =============================================================================
/// 정책 버전을 모델에 귀속시키기 위한 기준 테이블
model PolicyVersion {
id BigInt @id @default(autoincrement()) @map("id")
policyType PolicyType @map("policy_type")
versionCode String @map("version_code")
contentHash String @map("content_hash")
effectiveFrom DateTime @map("effective_from") @db.Timestamptz(6)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Relations
stores Store[]
contracts Contract[]
subsidyCases SubsidyCase[]
userConsents UserConsent[]
@@index([policyType, isActive], map: "idx_policy_type_active")
@@map("policy_versions")
}
/// 누가 무엇을 왜 바꿨는지를 운영 감사 기준으로 남긴다 (append-only)
model AuditLog {
id BigInt @id @default(autoincrement()) @map("id")
resourceType String @map("resource_type")
resourceId String @map("resource_id")
actionType String @map("action_type")
actorUserId BigInt? @map("actor_user_id")
actorRole String? @map("actor_role")
reasonCode String? @map("reason_code")
memo String? @map("memo")
beforeJson Json? @map("before_json")
afterJson Json? @map("after_json")
requestId String? @map("request_id")
correlationId String? @map("correlation_id")
ipHash String? @map("ip_hash")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Relations
actorUser User? @relation(fields: [actorUserId], references: [id])
@@index([resourceType, resourceId, createdAt], map: "idx_audit_resource_created")
@@index([actorUserId, createdAt], map: "idx_audit_actor_created")
@@index([actionType, createdAt], map: "idx_audit_action_created")
@@map("audit_logs")
}
/// 도메인 이벤트를 append-only로 보관
model EventLog {
id BigInt @id @default(autoincrement()) @map("id")
aggregateType String @map("aggregate_type")
aggregateId String @map("aggregate_id")
eventName String @map("event_name")
eventVersion Int @default(1) @map("event_version")
eventKey String @unique @map("event_key")
payloadJson Json @map("payload_json")
actorUserId BigInt? @map("actor_user_id")
causationId String? @map("causation_id")
correlationId String? @map("correlation_id")
piiLevel PiiLevel @default(NONE) @map("pii_level")
occurredAt DateTime @map("occurred_at") @db.Timestamptz(6)
recordedAt DateTime @default(now()) @map("recorded_at") @db.Timestamptz(6)
@@index([aggregateType, aggregateId, occurredAt], map: "idx_event_aggregate_occurred")
@@index([eventName, occurredAt], map: "idx_event_name_occurred")
@@map("event_logs")
}
/// 외부 발행/재시도/실패 처리를 EventLog와 분리해 관리
model OutboxEvent {
id BigInt @id @default(autoincrement()) @map("id")
aggregateType String @map("aggregate_type")
aggregateId String @map("aggregate_id")
eventName String @map("event_name")
payloadJson Json @map("payload_json")
publishStatus PublishStatus @default(PENDING) @map("publish_status")
availableAt DateTime @default(now()) @map("available_at") @db.Timestamptz(6)
retryCount Int @default(0) @map("retry_count")
lastError String? @map("last_error")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@index([publishStatus, availableAt], map: "idx_outbox_status_available")
@@map("outbox_events")
}
/// 웹훅과 중복 요청 방지를 위한 키 저장소
model IdempotencyKey {
id BigInt @id @default(autoincrement()) @map("id")
scope String @map("scope")
idempotencyKey String @map("idempotency_key")
requestHash String? @map("request_hash")
responseHash String? @map("response_hash")
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@unique([scope, idempotencyKey], map: "uq_idempotency_scope_key")
@@map("idempotency_keys")
}
/// 기능 플래그 토글
model FeatureFlag {
id BigInt @id @default(autoincrement()) @map("id")
flagKey String @unique @map("flag_key")
isEnabled Boolean @default(false) @map("is_enabled")
description String? @map("description")
enabledAt DateTime? @map("enabled_at") @db.Timestamptz(6)
disabledAt DateTime? @map("disabled_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
@@map("feature_flags")
}
// =============================================================================
// 8. 마스터 데이터
// =============================================================================
/// 지역 계층 마스터 (단일 계층 테이블 + self relation)
model RegionHierarchy {
id BigInt @id @default(autoincrement()) @map("id")
code String @unique @map("code")
nameKo String @map("name_ko")
fullNameKo String? @map("full_name_ko")
regionType RegionType @map("region_type")
parentId BigInt? @map("parent_id")
depth Int @default(0) @map("depth")
pathCode String? @map("path_code")
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
isBetaEnabled Boolean @default(false) @map("is_beta_enabled")
latitude Decimal? @map("latitude") @db.Decimal(10, 7)
longitude Decimal? @map("longitude") @db.Decimal(10, 7)
externalCode String? @map("external_code")
// Self relation
parent RegionHierarchy? @relation("RegionParentChild", fields: [parentId], references: [id])
children RegionHierarchy[] @relation("RegionParentChild")
// Relations
storesAsCluster Store[] @relation("StoreRegionCluster")
storesAsLeaf Store[] @relation("StoreRegionLeaf")
vendorsPrimaryRegion Vendor[]
vendorCoverageRegions VendorCoverageRegion[]
@@index([parentId], map: "idx_region_parent")
@@index([regionType, isActive], map: "idx_region_type_active")
@@index([isBetaEnabled, isActive], map: "idx_region_beta_active")
@@map("region_hierarchies")
}
/// 업종 분류 마스터 (단일 계층 테이블 + self relation)
model IndustryTaxonomy {
id BigInt @id @default(autoincrement()) @map("id")
code String @unique @map("code")
nameKo String @map("name_ko")
parentId BigInt? @map("parent_id")
depth Int @default(0) @map("depth")
sortOrder Int @default(0) @map("sort_order")
isLeaf Boolean @default(false) @map("is_leaf")
isActive Boolean @default(true) @map("is_active")
isBetaEnabled Boolean @default(false) @map("is_beta_enabled")
externalCode String? @map("external_code")
searchAliases String[] @map("search_aliases")
// Self relation
parent IndustryTaxonomy? @relation("IndustryParentChild", fields: [parentId], references: [id])
children IndustryTaxonomy[] @relation("IndustryParentChild")
// Relations
stores Store[]
@@index([parentId], map: "idx_industry_parent")
@@index([isLeaf, isActive], map: "idx_industry_leaf_active")
@@index([isBetaEnabled, isActive], map: "idx_industry_beta_active")
@@map("industry_taxonomies")
}