Monorepo skeleton, Prisma schema, core domain and services with tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-17 19:45:51 +04:00
commit 45d7512d2b
64 files changed
+9483

No files matched your search

+749
View File
@@ -0,0 +1,749 @@
// Barev Club — модель данных (PRD, раздел 7).
// Схема заложена сразу целиком; часть сущностей используется начиная с Этапов 2–3.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "esm"
}
datasource db {
provider = "postgresql"
}
// ───────────── Справочные перечисления ─────────────
enum Locale {
ru
en
zh
hy
es
}
/// Направление (раздел 2)
enum CategoryCode {
HOME
DOCS
LEARNING
PERKS
}
/// Сценарий заказа — определяет state machine и шаги исполнителя
enum ServiceFlow {
CLEANING
REPAIR
TRANSFER
LANGUAGE
CAREER
DOCUMENTS
}
enum ProviderRole {
CLEANER
HANDYMAN
DRIVER
TEACHER
CAREER_CONSULTANT
DOCS_AGENT
}
/// Все статусы всех сценариев. Допустимые переходы — в packages/core (flows).
enum OrderStatus {
NEW
ASSIGNED
EN_ROUTE
CLEANING
ON_SITE
ESTIMATE_APPROVED
REPAIRING
DRIVER_WAITING
RIDE_IN_PROGRESS
ENROLLED
LESSON_SCHEDULED
LESSON_IN_PROGRESS
LESSON_DONE
CONSULTANT_ASSIGNED
RESUME_RECEIVED
MEETING
PLAN_SENT
DOCS_REVIEW
NEEDS_MORE_DOCS
SUBMITTED
DONE
COMPLETED
CANCELLED
}
enum ActorType {
CLIENT
PROVIDER
DISPATCHER
ADMIN
SYSTEM
}
enum OfferStatus {
OFFERED
ACCEPTED
DECLINED
EXPIRED
REVOKED
}
enum PaymentStatus {
UNPAID
PAID
REFUNDED
}
enum RateType {
FIXED
PERCENT
}
enum AdminRole {
OWNER
OPERATOR
}
enum SubscriptionPlan {
BAREV_PLUS
}
enum SubscriptionSource {
AGENCY_GIFT
MANUAL
PAID
}
enum SubscriptionStatus {
ACTIVE
EXPIRED
CANCELLED
}
enum PromoKind {
AGENCY_GIFT
DISCOUNT
}
enum FileKind {
PHOTO_REPORT
CLIENT_PHOTO
DOCUMENT_SCAN
RESUME
OTHER
}
enum EstimateStatus {
PENDING
ACCEPTED
REJECTED
}
enum TaskStatus {
OPEN
DONE
}
enum PayoutStatus {
DRAFT
APPROVED
PAID
}
// ───────────── Пользователи ─────────────
/// Пользователь Telegram. Один и тот же человек может быть клиентом и исполнителем.
model User {
id String @id @default(cuid())
telegramId BigInt @unique
username String?
firstName String?
lastName String?
phone String?
locale Locale @default(ru)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client?
provider Provider?
dispatcher CompanyDispatcher[]
messages Message[]
}
model Client {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
consentAt DateTime?
consentVersion String?
agencyId String?
agency Agency? @relation(fields: [agencyId], references: [id])
agencyAttachedAt DateTime?
/// источник: deeplink | manual_code | organic
source String @default("organic")
utm Json?
/// подарочный месяц уже использован (один раз на клиента)
giftUsedAt DateTime?
/// короткий код клубной карты (Этап 3)
cardCode String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
subscriptions Subscription[]
ratings Rating[]
cardUsages CardUsage[]
promoUses PromoCodeUse[]
@@index([agencyId])
}
// ───────────── Исполнители ─────────────
model ProviderCompany {
id String @id @default(cuid())
name String
/// какие роли исполнителей есть в компании (какие услуги она берёт)
roles ProviderRole[]
/// договорная ставка компании
rateType RateType @default(PERCENT)
rateValue Int @default(70)
contactPhone String?
inviteCode String @unique
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
employees Provider[]
dispatchers CompanyDispatcher[]
orders Order[]
offers OrderOffer[]
payouts Payout[]
}
model CompanyDispatcher {
id String @id @default(cuid())
companyId String
company ProviderCompany @relation(fields: [companyId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@unique([companyId, userId])
}
model Provider {
id String @id @default(cuid())
/// заполняется, когда исполнитель открыл бота по инвайт-коду
userId String? @unique
user User? @relation(fields: [userId], references: [id])
name String
phone String?
role ProviderRole
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
languages Locale[]
/// ставка фрилансера; для сотрудников компании не используется
rateType RateType @default(PERCENT)
rateValue Int @default(70)
ratingSum Int @default(0)
ratingCount Int @default(0)
onShift Boolean @default(false)
active Boolean @default(true)
inviteCode String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
districts ProviderDistrict[]
schedule ProviderSchedule[]
orders Order[]
offers OrderOffer[]
ratings Rating[]
payouts Payout[]
@@index([role, active])
}
model District {
id String @id @default(cuid())
code String @unique
sortOrder Int @default(0)
providers ProviderDistrict[]
orders Order[]
}
model ProviderDistrict {
providerId String
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
districtId String
district District @relation(fields: [districtId], references: [id], onDelete: Cascade)
@@id([providerId, districtId])
}
/// Недельный график: 0 = понедельник … 6 = воскресенье, минуты от полуночи по Asia/Yerevan
model ProviderSchedule {
id String @id @default(cuid())
providerId String
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
weekday Int
startMinute Int
endMinute Int
@@index([providerId, weekday])
}
// ───────────── Каталог ─────────────
model Category {
id String @id @default(cuid())
code CategoryCode @unique
sortOrder Int @default(0)
/// скидка Barev+ на направление, %
barevPlusDiscountPct Int @default(0)
active Boolean @default(true)
services Service[]
}
model Service {
id String @id @default(cuid())
/// код услуги; тексты берутся из i18n по ключу services.<code>.*
code String @unique
categoryId String
category Category @relation(fields: [categoryId], references: [id])
flow ServiceFlow
providerRole ProviderRole
/// правила цены (см. packages/core/src/pricing)
pricing Json
/// описание параметров формы заказа (см. packages/core/src/params)
paramsSchema Json
/// окно записи и шаг слотов (см. packages/core/src/slots)
slotConfig Json
/// удержание при поздней отмене, %
lateCancelFeePct Int @default(30)
isOnline Boolean @default(false)
active Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
checklist ChecklistItem[]
orders Order[]
}
/// Шаблон пункта чек-листа услуги
model ChecklistItem {
id String @id @default(cuid())
serviceId String
service Service @relation(fields: [serviceId], references: [id], onDelete: Cascade)
/// код пункта; подпись — i18n checklist.<code>
code String
requiresPhoto Boolean @default(false)
sortOrder Int @default(0)
@@unique([serviceId, code])
}
model PaymentMethod {
id String @id @default(cuid())
code String @unique
/// { ru, en, zh, hy, es }
name Json
active Boolean @default(true)
sortOrder Int @default(0)
orders Order[]
}
// ───────────── Заказы ─────────────
model Order {
id String @id @default(cuid())
number Int @unique @default(autoincrement())
clientId String
client Client @relation(fields: [clientId], references: [id])
serviceId String
service Service @relation(fields: [serviceId], references: [id])
params Json
scheduledAt DateTime
durationMinutes Int
isOnline Boolean @default(false)
addressText String?
districtId String?
district District? @relation(fields: [districtId], references: [id])
lat Float?
lng Float?
note String?
/// язык клиента на момент заказа
locale Locale
basePrice Int
discountPct Int @default(0)
discountAmount Int @default(0)
totalPrice Int
status OrderStatus @default(NEW)
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
/// заказ передан компании; пока providerId пуст — «Назначен на компанию»
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
paymentMethodId String?
paymentMethod PaymentMethod? @relation(fields: [paymentMethodId], references: [id])
paymentStatus PaymentStatus @default(UNPAID)
paidAt DateTime?
cancelledAt DateTime?
cancelFee Int @default(0)
cancelReason String?
completedAt DateTime?
repeatOfId String?
reminded24hAt DateTime?
reminded1hAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
history OrderStatusHistory[]
checklist OrderChecklistItem[]
offers OrderOffer[]
files OrderFile[]
messages Message[]
estimates Estimate[]
rating Rating?
lessons LessonPackage?
payments Payment[]
commission AgencyCommission?
tasks OperatorTask[]
@@index([status])
@@index([scheduledAt])
@@index([clientId])
@@index([providerId, scheduledAt])
}
model OrderStatusHistory {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
fromStatus OrderStatus?
toStatus OrderStatus
/// событие без смены статуса (например «Я на месте») или код действия
event String?
actorType ActorType
actorId String?
comment String?
createdAt DateTime @default(now())
@@index([orderId, createdAt])
}
/// Предложение заказа исполнителю или компании (принять / отклонить с таймаутом)
model OrderOffer {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
status OfferStatus @default(OFFERED)
expiresAt DateTime?
respondedAt DateTime?
createdById String?
createdAt DateTime @default(now())
@@index([orderId, status])
@@index([providerId, status])
@@index([companyId, status])
}
model OrderChecklistItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
code String
requiresPhoto Boolean @default(false)
sortOrder Int @default(0)
checkedAt DateTime?
fileId String? @unique
file OrderFile? @relation(fields: [fileId], references: [id])
@@unique([orderId, code])
}
model OrderFile {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
kind FileKind
storageKey String @unique
mime String
size Int
uploadedBy ActorType
uploadedById String?
/// дата автоудаления (документы — через N дней после завершения)
deleteAfter DateTime?
deletedAt DateTime?
createdAt DateTime @default(now())
checklistItem OrderChecklistItem?
accessLog FileAccessLog[]
}
model FileAccessLog {
id String @id @default(cuid())
fileId String
file OrderFile @relation(fields: [fileId], references: [id], onDelete: Cascade)
actorType ActorType
actorId String?
createdAt DateTime @default(now())
}
/// Смета ремонта (Этап 2)
model Estimate {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
items Json
total Int
status EstimateStatus @default(PENDING)
respondedAt DateTime?
createdAt DateTime @default(now())
}
/// Чат клиент ↔ исполнитель через бота (Этап 2)
model Message {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
fromType ActorType
fromUserId String?
fromUser User? @relation(fields: [fromUserId], references: [id])
text String?
fileId String?
createdAt DateTime @default(now())
@@index([orderId, createdAt])
}
model Rating {
id String @id @default(cuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
clientId String
client Client @relation(fields: [clientId], references: [id])
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
score Int
comment String?
createdAt DateTime @default(now())
}
/// Пакет уроков (Этап 2)
model LessonPackage {
id String @id @default(cuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
total Int
completed Int @default(0)
}
model Payment {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
/// manual | <будущий провайдер>
provider String
amount Int
status PaymentStatus
externalId String?
markedById String?
createdAt DateTime @default(now())
}
model OperatorTask {
id String @id @default(cuid())
type String
orderId String?
order Order? @relation(fields: [orderId], references: [id], onDelete: Cascade)
status TaskStatus @default(OPEN)
note String?
createdAt DateTime @default(now())
doneAt DateTime?
}
// ───────────── Агентства, промокоды, членство ─────────────
model Agency {
id String @id @default(cuid())
/// код для deep-link и ручного ввода (латиница/цифры)
code String @unique
name String
/// вознаграждение, % от оплаченных заказов клиентов агентства
commissionPct Int @default(10)
contact String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clients Client[]
commissions AgencyCommission[]
promoCodes PromoCode[]
}
model AgencyCommission {
id String @id @default(cuid())
agencyId String
agency Agency @relation(fields: [agencyId], references: [id])
orderId String @unique
order Order @relation(fields: [orderId], references: [id])
pct Int
amount Int
/// YYYY-MM
period String
createdAt DateTime @default(now())
@@index([agencyId, period])
}
model PromoCode {
id String @id @default(cuid())
code String @unique
kind PromoKind
agencyId String?
agency Agency? @relation(fields: [agencyId], references: [id])
discountPct Int?
maxUses Int?
usedCount Int @default(0)
validFrom DateTime?
validUntil DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
uses PromoCodeUse[]
}
model PromoCodeUse {
id String @id @default(cuid())
promoId String
promo PromoCode @relation(fields: [promoId], references: [id])
clientId String
client Client @relation(fields: [clientId], references: [id])
orderId String?
createdAt DateTime @default(now())
@@unique([promoId, clientId])
}
model Subscription {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
plan SubscriptionPlan @default(BAREV_PLUS)
source SubscriptionSource
status SubscriptionStatus @default(ACTIVE)
startsAt DateTime
endsAt DateTime
remindedAt DateTime?
createdAt DateTime @default(now())
@@index([clientId, endsAt])
}
// ───────────── Партнёры и клубная карта (Этап 3) ─────────────
model Partner {
id String @id @default(cuid())
/// cafe | restaurant | other
category String
name String
/// { ru, en, ... }
offer Json
terms Json?
address String?
active Boolean @default(true)
createdAt DateTime @default(now())
usages CardUsage[]
}
model CardUsage {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id])
partnerId String?
partner Partner? @relation(fields: [partnerId], references: [id])
createdAt DateTime @default(now())
}
// ───────────── Финансы ─────────────
model Payout {
id String @id @default(cuid())
providerId String?
provider Provider? @relation(fields: [providerId], references: [id])
companyId String?
company ProviderCompany? @relation(fields: [companyId], references: [id])
periodStart DateTime
periodEnd DateTime
amount Int
status PayoutStatus @default(DRAFT)
createdAt DateTime @default(now())
}
// ───────────── Админка ─────────────
model AdminUser {
id String @id @default(cuid())
email String @unique
name String
passwordHash String
role AdminRole @default(OPERATOR)
totpSecret String?
totpEnabled Boolean @default(false)
active Boolean @default(true)
createdAt DateTime @default(now())
sessions AdminSession[]
audit AuditLog[]
}
model AdminSession {
id String @id @default(cuid())
adminUserId String
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
/// sha256 токена сессии
tokenHash String @unique
/// сессия прошла 2FA
verified Boolean @default(false)
expiresAt DateTime
createdAt DateTime @default(now())
}
model AuditLog {
id String @id @default(cuid())
adminUserId String?
adminUser AdminUser? @relation(fields: [adminUserId], references: [id])
action String
entity String
entityId String?
data Json?
createdAt DateTime @default(now())
@@index([entity, entityId])
}
/// Настройки (ключ → значение). Список ключей и дефолты — packages/core/src/settings.ts
model Setting {
key String @id
value Json
updatedAt DateTime @updatedAt
}
/// Короткий токен для deep-link с лендинга, когда payload не влезает в 64 символа (UTM)
model StartToken {
id String @id
locale Locale?
agency String?
utm Json?
createdAt DateTime @default(now())
}