Схема базы данных (Prisma)
🗂️ Интерактивная схема — ER-диаграммы всех доменов, поля каждой таблицы, политика удаления на связях и поиск по таблицам. Страница собирается из
schema.prismaпри каждой сборке документации, поэтому не расходится с кодом. Локально:npm run dev:docs, затем/schema/.
Единая PostgreSQL-схема проекта описана в файле
apps/core-api/prisma/schema.prisma.
Это единственный источник истины для структуры БД: из него генерируется Prisma-клиент
(@prisma/client), который переиспользуется всеми сервисами монорепозитория через
@crewsforge-back/apis/utils/prisma-client (libs/apis/utils/prisma-client).
Технические особенности схемы:
- СУБД — PostgreSQL (
datasource db), строка подключения берётся изenv("POSTGRES_URL"). - Генератор клиента —
prisma-client-js,binaryTargets = ["native", "linux-musl-openssl-3.0.x"](второй таргет нужен для запуска в Alpine/musl-контейнерах). - Все первичные ключи —
UUIDсо значением по умолчаниюgen_random_uuid()на стороне БД (@default(dbgenerated("gen_random_uuid()")) @db.Uuid). - Имена таблиц и колонок в БД задаются через
@@map/@mapв snake_case, модели и поля в коде — в camelCase. - Аудиторские поля
created_at/updated_atесть у большинства таблиц. У append-only журналов (state_transitions,outbox_events,artifacts,milestone_submissions,specifications,dispute_evidences,concierge_messages,notifications,timers,webhook_events,walrider_thread_states) и у части связокupdated_atнет намеренно: строка не меняется после записи. - Деньги хранятся только в минорных единицах (
BigInt, поля*Minor), валюта — на проекте (Project.currency), у денежных записей не дублируется.Decimalв схеме не используется. - Доли и ставки — в basis points (
Int, поля*Bps):commissionRateBps,teamShareBps,completedCriteriaBps. - Всего в схеме 61 модель и 42 enum-типа.
Схема покрывает весь домен платформы — от заявки заказчика до расчёта по этапу и разбора спора — и является целевой: часть таблиц ещё не имеет пишущего кода, но их форма зафиксирована baseline’ом и раскатана на всех окружениях.
| # | Домен | Модели | Кто пишет |
|---|---|---|---|
| 1 | Пользователи, роли, профили | 19 | auth-api, user-api |
| 2 | Токены и сессии | 2 | auth-api |
| 3 | Гео-справочник | 5 | сид GeoSeeder |
| 4 | Проект | 1 | project-api, admin-api |
| 5 | Walrider (AI-агент) | 3 | ai-agent-service, project-api |
| 6 | Команда | 3 | user-api |
| 7 | Спецификация и план | 2 | project-api |
| 8 | Этапы, критерии, сдача, разбор | 6 | project-api |
| 9 | Контрактинг | 4 | project-api |
| 10 | Деньги | 4 | сервис биллинга (единственный пишущий) |
| 11 | Спор и расторжение | 4 | project-api, admin-api |
| 12 | Операционный контур | 5 | платформенный воркер; читают рабочие места admin-api |
| 13 | Консьерж | 2 | admin-api |
| 14 | Интеграция трекера | 1 | интеграционный слой |
Колонка «кто пишет» отражает проектное разграничение из
docs/crewsforge-architecture-session-2026-08-21/;
сервисы биллинга и воркера в репозитории пока не заведены.
Политика удаления
Заголовок раздела «Политика удаления»Правило одно: сделочная цепочка не удаляется каскадом. Строки проекта, этапов, контрактов, холдов, расчётов, споров и расторжений — доказательство того, что произошло между сторонами, и удаление вышестоящей записи не имеет права их стереть.
| Участок | Правило | Почему |
|---|---|---|
User → Customer, User → Employee | Restrict | под пользователем лежит сделочная история |
Customer → Project, Team → Project | Restrict | проект — корень цепочки |
Project → Specification / PlanVersion / Milestone / Party / Contract / Offer / Termination | Restrict | документы сделки |
Milestone → Artifact / Submission / Review / Hold / Dispute / Criterion | Restrict | сдача и приёмка |
Hold → Settlement, Party → PartyDocument, Dispute → Position → Evidence | Restrict | деньги и доказательства |
Team → TeamMember, Employee → TeamMember | Restrict | подписи и сдачи ссылаются на членство; ушедший участник получает status = REMOVED |
Профильные сателлиты (Profile, Avatar, связки навыков, языков, портфолио, токены, сессии) | Cascade | производные данные, доказательной ценности не несут |
Project → WalriderThread | Cascade | тред консультации — не часть сделки |
Profile → Timezone / Location | SetNull | справочник может измениться, профиль остаётся |
Всего в схеме 27 связей с onDelete: Restrict.
Жёсткого удаления пользователя не существует. Запрос на удаление аккаунта — это User.deletedAt
плюс анонимизация персональных данных; сделочные записи остаются. Мягкое удаление есть и у проекта
(Project.deletedAt), но только в статусе DRAFT и только на уровне сервиса.
Правило проверяется интеграционным тестом
apps/core-api-e2e/src/schema/deal-chain-foreign-keys.integration.spec.ts:
он читает information_schema и падает, если на любом внешнем ключе сделочной таблицы появилось
CASCADE или незнакомый ключ.
Инварианты на уровне БД
Заголовок раздела «Инварианты на уровне БД»Prisma Schema Language не умеет ни CHECK-констрейнтов, ни частичных уникальных индексов, поэтому
три правила живут в baseline-миграции как raw SQL. Держать их в приложении нельзя: их нарушение
обязано отвергаться базой даже при гонке двух транзакций или при прямом доступе к БД.
| Инвариант | Форма | Что запрещает |
|---|---|---|
settlements_amounts_sum_to_hold | CHECK | расчёт по холду, в котором team + founder + fee ≠ hold_amount_minor. Денормализованное hold_amount_minor лежит в самой строке settlements ровно ради этой проверки: CHECK не умеет ходить в соседнюю таблицу |
queue_items_open_dedup | частичный UNIQUE по (type, subject_type, subject_id) WHERE status IN ('OPEN','IN_PROGRESS') | две незакрытые задачи очереди по одному предмету. Повторная задача того же типа заводится свободно после того, как предыдущая ушла в DONE/CANCELLED |
users_on_roles_single_platform_role | частичный UNIQUE по (user_id) WHERE role_id IN (<три uuid>) | совмещение двух платформенных ролей одним человеком. Клиентские роли (CUSTOMER, EMPLOYEE) в предикат не входят — их сочетать можно |
roles_type_key | UNIQUE по roles.type | вторую строку справочника ролей того же типа. Без неё предыдущий индекс обходится: он перечисляет id платформенных ролей, и роль ADMIN с другим uuid его предикат не видит |
Чего эти инварианты не покрывают
Заголовок раздела «Чего эти инварианты не покрывают»Граница названа прямо, чтобы на неё не рассчитывали как на защиту:
CHECKсверяет расчёт с денормализованнымhold_amount_minor, а не с самим холдом. Строка, гдеhold_amount_minorне равенholds.amount_minor, базой принимается. Сверка — транзакционный инвариант сервиса, вводится вместе с расчётом выплаты (M4).- Число расчётов по одному холду ничем не ограничено. Три полные выплаты одного холда база
примет. Единственность неотменённого расчёта — тоже правило
M4/M5:SPLITпо модели шага 2 §7 это одна строка с двумя ненулевыми суммами, а не две строки. executed_by_user_id,assignee_user_id,closed_by_user_id— uuid без внешнего ключа. Так объявлено шагом 2 (полиморфные и actor-ссылки живут без FK); ссылка на несуществующего человека базой принимается. ДляSettlement, который по A-1/D-5 является доказательством «кто исполнил выплату», это осознанная слабость — усиливать её решаетM4.
Предикат третьего индекса содержит литеральные uuid, а не подзапрос к
roles: предикат частичного индекса обязан бытьIMMUTABLE. Те же значения продублированы вlibs/apis/shared/src/lib/constants/roles.constants.tsи в сиде ролей. Менять их можно только вместе с пересозданием индекса миграцией.
Тексты и обоснования — в хвосте
apps/core-api/prisma/migrations/20260830000000_baseline/migration.sql;
поведение закреплено интеграционными тестами apps/core-api-e2e/src/schema/*.integration.spec.ts.
1. Пользователи, роли, профили
Заголовок раздела «1. Пользователи, роли, профили»erDiagram User ||--o| Customer : "customer" User ||--o| Employee : "employee" User ||--o| Profile : "profile" User ||--o{ UserOnRole : "userOnRole" Role ||--o{ UserOnRole : "userOnRole"
Profile ||--o| Avatar : "avatar" Profile ||--o| CustomerProfile : "customerProfile" Profile ||--o| EmployeeProfile : "employeeProfile" Profile ||--o{ ProfileLanguageLink : "languages" ProfileLanguage ||--o{ ProfileLanguageLink : "profiles"
Customer ||--o| CustomerProfile : "customerProfile" Employee ||--o| EmployeeProfile : "employeeProfile" Employee ||--o{ TeamMember : "teamMembers"
EmployeeProfile ||--o{ EmployeeProfileSkill : "skills" EmployeeProfile ||--o{ EmployeeProfileSpecialization : "specializations" EmployeeProfile ||--o{ Portfolio : "portfolios"
Skill ||--o{ EmployeeProfileSkill : "employeeProfilesSkills" Skill ||--o{ PortfolioSkill : "portfoliosSkills" Specialization ||--o{ EmployeeProfileSpecialization : "employeeProfilesSpecializations" Specialization ||--o{ Portfolio : "portfolios"
Portfolio ||--o{ PortfolioSkill : "skills" Portfolio ||--o{ PortfolioLink : "links" Portfolio ||--o{ PortfolioMedia : "media"| Модель (таблица) | Назначение | Ключевые поля | Ограничения и индексы |
|---|---|---|---|
User (users) | учётная запись | email, phone, passwordHash/passwordSalt (nullable — у платформенных пользователей пароля нет), isEmailVerified, isBlocked, deletedAt | @@unique на email и phone, @@index([isBlocked]) |
Role (roles) | справочник ролей | type: RoleType | строки — данные baseline’а с детерминированными id (см. seeds) |
UserOnRole (users_on_roles) | связка пользователь ↔ роль | userId, roleId | @@unique([userId, roleId]) + частичный индекс одной платформенной роли |
Customer (customers) | сторона-заказчик (фаундер) | userId | @@unique(userId), user → Restrict |
Employee (employees) | сторона-исполнитель | userId, bio | @@unique(userId), user → Restrict |
Profile (profiles) | персональные данные | firstName, lastName, timezoneId, locationId | @@unique(userId); справочники — SetNull |
Avatar (avatars) | аватар профиля | url, format | @@unique(profileId) |
CustomerProfile (customers_profiles) | связь профиля с заказчиком | customerId, profileId | оба @unique |
EmployeeProfile (employee_profiles) | профессиональная часть профиля | experienceMonths, workType: WorkType | employeeId/profileId @unique |
EmployeeProfileSkill (employees_profiles_skills) | навыки исполнителя | — | @@unique([employeeProfileId, skillId]), без полей аудита |
EmployeeProfileSpecialization (employees_profiles_specializations) | специализации исполнителя | — | @@unique([employeeProfileId, specializationId]), без полей аудита |
Skill (skills) | справочник навыков | name, description | name @unique |
Specialization (specializations) | справочник специализаций | name, description | name @unique |
ProfileLanguage (profile_languages) | справочник языков | code, name | code @unique |
ProfileLanguageLink (profiles_languages) | язык профиля с уровнем | level | @@unique([profileId, languageId]), без полей аудита |
Portfolio (portfolios) | кейс исполнителя | title, description, specializationId | — |
PortfolioSkill (portfolios_skills) | навыки кейса | — | @@unique([portfolioId, skillId]) |
PortfolioLink (portfolios_links) | ссылки кейса | type: PortfolioLinkType, url | — |
PortfolioMedia (portfolios_media) | медиа кейса | url, format | — |
2. Токены и сессии
Заголовок раздела «2. Токены и сессии»| Модель (таблица) | Назначение | Ключевые поля | Ограничения |
|---|---|---|---|
Token (tokens) | пара access/refresh | accessTokenId, accessTokenExpiredAt (unix, Int), refreshTokenId, refreshTokenExpiredAt | user → Cascade, session 1:1 |
Session (sessions) | сессия устройства | tokenId, fingerprint, ip (@map("ip_address")) | tokenId @unique, @@unique([userId, fingerprint]) |
3. Гео-справочник
Заголовок раздела «3. Гео-справочник»Наполняется сидом GeoSeeder (см. seeds), кодом платформы не пишется.
| Модель (таблица) | Назначение | Ключевые поля | Ограничения и индексы |
|---|---|---|---|
Timezone (timezones) | таймзона вида UTC+01:00 | name | name @unique |
Country (countries) | страна из RestCountries | ISO-коды cca2/cca3/ccn3/cioc, nameCommon/nameOfficial, region/subregion, координаты, population: BigInt, JSON-блоки (languages, currencies, flags, maps, translations, …) | четыре ISO-кода @unique; индексы по region, subregion, nameCommon |
Location (locations) | дерево локаций | type: LocationType, name, parentId (самоссылка LocationTree), countryId, lat/lng, meta | @@unique([type, name, parentId]); индексы по [type, name], countryId, parentId |
CountryTimezone (countries_timezones) | связка страна ↔ таймзона | — | @@unique([countryId, timezoneId]), @@index([timezoneId]) |
LocationTimezone (locations_timezones) | связка локация ↔ таймзона | — | @@unique([locationId, timezoneId]), @@index([timezoneId]) |
4. Проект
Заголовок раздела «4. Проект»Project — корень сделочной цепочки. Все денежные и организационные атрибуты сделки висят на нём.
| Поле | Тип | Описание |
|---|---|---|
id | String @db.Uuid | PK |
title, description | String | заголовок и описание |
category | ProjectCategoryType? | категория, необязательна |
status | ProjectStatus @default(DRAFT) | статус, 10 значений |
contractingStage | ContractingStage? | подстадия; заполнена только в статусе CONTRACTING |
customerId | String @db.Uuid | владелец (Customer), Restrict |
teamId | String? @db.Uuid | назначается при мэтчинге (Team), Restrict |
currency | String @default("USD") @db.Char(3) | валюта сделки — одна на проект |
budgetMinMinor, budgetMaxMinor | BigInt? | вилка бюджета AI-этапа, в минорных единицах |
belowBudgetGate | Boolean @default(false) | мягкий гейт: проект ниже порога требует ручного одобрения на мэтчинг |
commissionRateBps | Int? | ставка комиссии в basis points, снапшот на контрактинге |
createdAt, updatedAt | DateTime | аудит |
deletedAt | DateTime? | мягкое удаление, допустимо только в DRAFT |
Индексы: customerId, teamId, status, category, deletedAt.
Связи 1:N: specifications, planVersions, milestones, parties, contracts, offers,
terminations (все Restrict); 1:1 — walriderThread (Cascade).
Бюджет проекта — только вилка: точная сумма появляется в
PlanVersion.totalAmountMinorи в суммах этапов. Единого поля «цена проекта» в схеме нет намеренно.
Смены статуса через HTTP сейчас нет ни в project-api, ни в admin-api: прежняя матрица переходов
снята вместе с маршрутами (см. project-api).
5. Walrider (AI-агент)
Заголовок раздела «5. Walrider (AI-агент)»| Модель (таблица) | Назначение | Ключевые поля | Ограничения и индексы |
|---|---|---|---|
WalriderThread (walrider_threads) | тред диалога, 1:1 с проектом | customerId, projectId, externalThreadId | projectId @unique, @@index([customerId]), project → Cascade |
WalriderMessage (walrider_messages) | сообщение треда | messageId (autoincrement), role: WalriderMessageRole, content, externalMessageId, metadata | @@index([threadId]) |
WalriderThreadState (walrider_thread_states) | снимок состояния треда/сообщения | status, data | индексы по threadId, messageId; только createdAt |
6. Команда
Заголовок раздела «6. Команда»erDiagram Team ||--o{ TeamMember : "members" Team ||--o{ TeamInvitation : "invitations" Team ||--o| PayoutAccount : "payoutAccount" Team ||--o{ Project : "projects" Team ||--o{ Offer : "offers" Employee ||--o{ TeamMember : "teamMembers"| Модель (таблица) | Назначение | Ключевые поля | Ограничения и индексы |
|---|---|---|---|
Team (teams) | команда-исполнитель | name, slug, bio, status: TeamStatus, externalExperience (кейсы до появления истории на платформе), verifiedAt | slug @unique, @@index([status]) |
TeamMember (team_members) | членство сотрудника в команде | role: TeamMemberRole, canSign, canSubmit, status: TeamMemberStatus, removedAt | @@unique([teamId, employeeId]), @@index([employeeId]), обе связи Restrict |
TeamInvitation (team_invitations) | приглашение в команду по e-mail | email, role, token, status: InvitationStatus, expiresAt | token @unique, @@index([teamId]), team → Cascade |
Две оси членства независимы.
role— уровень доступа к рабочему пространству (ADMIN/EDITOR/VIEWER),canSign/canSubmit— полномочия подписать контракт и сдать этап. Администратор команды может не иметь права сдачи, наблюдатель — иметь. Ушедший участник не удаляется, а переводится вstatus = REMOVED: на членство ссылаются подписи и сдачи.
7. Спецификация и план
Заголовок раздела «7. Спецификация и план»| Модель (таблица) | Назначение | Ключевые поля | Ограничения |
|---|---|---|---|
Specification (specifications) | версия спецификации проекта | version, content (структурированная спека; бюджет — только вилка {minMinor, maxMinor}), authorType: AuthorType, authorUserId, status: SpecificationStatus, sourceThreadId | @@unique([projectId, version]), только createdAt |
PlanVersion (plan_versions) | версия плана этапов | version, items (JSON: orderIndex, title, description, amountMinor, durationDays, criteria[]), totalAmountMinor, status: PlanVersionStatus, internalNotes, authorMemberId, sentAt, respondedAt, rejectReason | @@unique([projectId, version]) |
PlanVersion.internalNotes— торг платформы с командой. Это поле никогда не попадает в DTO заказчика; авторство фиксируется полемauthorTypeспецификации, чтобы AI-черновик не выдавался за человеческий.
8. Этапы, критерии, сдача, разбор
Заголовок раздела «8. Этапы, критерии, сдача, разбор»erDiagram Project ||--o{ Milestone : "milestones" Milestone ||--o{ AcceptanceCriterion : "criteria" Milestone ||--o{ Artifact : "artifacts" Milestone ||--o{ MilestoneSubmission : "submissions" Milestone ||--o{ AcceptanceReview : "reviews" Milestone ||--o| Hold : "hold" Milestone ||--o{ Dispute : "disputes" AcceptanceReview ||--o{ ReviewRejectionItem : "items"| Модель (таблица) | Назначение | Ключевые поля | Ограничения и индексы |
|---|---|---|---|
Milestone (milestones) | оплачиваемый этап работ | planVersionId (null = этап создан из разбора), orderIndex, title, description, amountMinor, status: MilestoneStatus, dueAt, deadlinePausedAt | @@unique([projectId, orderIndex]), @@index([status]) |
AcceptanceCriterion (acceptance_criteria) | проверяемый критерий приёмки | orderIndex, text, state: CriterionState | @@unique([milestoneId, orderIndex]) |
Artifact (artifacts) | результат работы | type: ArtifactType, title, url, fileKey, uploaderMemberId, source: ArtifactSource | @@index([milestoneId]), ни deletedAt, ни каскада: артефакты оплаченных этапов остаются у заказчика навсегда |
MilestoneSubmission (milestone_submissions) | попытка сдачи этапа | attempt, submittedByMemberId (требует canSubmit), coverNote | @@unique([milestoneId, attempt]); лимита пересдач нет |
AcceptanceReview (acceptance_reviews) | разбор непринятия | submissionId, openedByUserId, verdict: ReviewVerdict, reasoning, decidedByUserId, newMilestoneId, decidedAt | обоснование обязательно при вердикте |
ReviewRejectionItem (review_rejection_items) | пункт отказа | criterionId, comment | @@unique([reviewId, criterionId]) |
Отказ принять этап обязательно привязан к критерию (
ReviewRejectionItem.criterionId); свободный комментарий — только дополнение. Разбор и спор останавливают срок этапа: на входе проставляетсяdeadlinePausedAt, на выходеdueAtсдвигается на длительность паузы.
9. Контрактинг
Заголовок раздела «9. Контрактинг»| Модель (таблица) | Назначение | Ключевые поля | Ограничения |
|---|---|---|---|
Party (parties) | снапшот юридических данных стороны на проект | side: DealSide, legalName, legalForm, countryCode, registrationNumber, taxId, address (JSON), kycState: KycState | @@unique([projectId, side]) |
PartyDocument (party_documents) | документ стороны | kind, fileKey, status: PartyDocumentStatus, reviewedByUserId | — |
Contract (contracts) | договор со стороной | side (TEAM = договор A, FOUNDER = договор B), planVersionId (приложение к рамочному), status: ContractStatus, templateVersion, documentHash (sha256 отправленного PDF), envelopeExternalId, signerUserId/signerMemberId, signedAt, signedFileKey, terminatedAt | @@unique([projectId, side]) |
Offer (offers) | предложение команде и заказчику | teamId, state: OfferState, createdByUserId, declineReason, respondedAt | индексы по projectId, teamId |
Юрданные — снапшот на проект, а не общая переиспользуемая карточка: реквизиты на момент подписания обязаны остаться неизменными. Договоров всегда два, и они расторгаются независимо (
Contract.terminatedAtна каждой стороне). Порядок оффера — сначала команда, затем заказчик — зашит вOfferState.
10. Деньги
Заголовок раздела «10. Деньги»Единственный пишущий сервис этого домена — биллинг. Остальные читают.
| Модель (таблица) | Назначение | Ключевые поля | Ограничения |
|---|---|---|---|
PayoutAccount (payout_accounts) | выплатной аккаунт команды | provider (stripe), externalAccountId, state: PayoutAccountState, requirements (JSON currently_due провайдера) | teamId @unique |
Hold (holds) | заморозка суммы этапа | amountMinor, state: HoldState, paymentIntentExternalId, chargeExternalId, heldAt, closedAt | milestoneId @unique |
Settlement (settlements) | распределение холда | teamAmountMinor, founderAmountMinor, platformFeeMinor, holdAmountMinor (денормализация ради CHECK), basisType: SettlementBasis, basisId, executedByUserId, status: SettlementStatus, transferExternalId, refundExternalId, executedAt | CHECK settlements_amounts_sum_to_hold |
WebhookEvent (webhook_events) | входящее событие провайдера | provider: WebhookProvider, externalEventId, payload, status: WebhookEventStatus, processedAt | @@unique([provider, externalEventId]) — идемпотентность: повторное событие не порождает повторного движения денег |
Settlement.basisType+basisId— обязательное основание расчёта: разбор, решение арбитра, оценка при расторжении или двойное одобрение. Расчёта без основания в схеме быть не может.
11. Спор и расторжение
Заголовок раздела «11. Спор и расторжение»| Модель (таблица) | Назначение | Ключевые поля | Ограничения |
|---|---|---|---|
Dispute (disputes) | спор по этапу | state: DisputeState, initiatorSide, openedByUserId, positionDeadlineAt, outcome: DisputeOutcome, teamShareBps (при PARTIAL), reasoning, arbiterUserId, resolvedAt, lciaEscalatedAt | спор открывается только по этапу в FUNDED и позже |
DisputePosition (dispute_positions) | позиция стороны | side, statement, submittedByUserId, submittedAt | @@unique([disputeId, side]); statement = null при истёкшем таймере — зафиксированный факт неответа |
DisputeEvidence (dispute_evidences) | доказательство к позиции | title, fileKey, url | только createdAt |
Termination (terminations) | расторжение проекта | state: TerminationState, initiatorSide, requestedByUserId, basis: TerminationBasis, objectionDeadlineAt, objectionByUserId, objectionDisputeId, completedCriteriaBps (мера выполненного объёма), assessedByUserId, reasoning, sourceDisputeId, executedAt | — |
lciaEscalatedAt— не расторжение, а долгая пауза: эскалация во внешний арбитраж останавливает внутренний процесс, не закрывая проект. Возражение против расторжения превращается в спор (objectionDisputeId).
12. Операционный контур
Заголовок раздела «12. Операционный контур»Пять таблиц без единого внешнего ключа: предмет задаётся парой subjectType + subjectId
(полиморфная ссылка). Это осознанный выбор — одна очередь и один журнал обслуживают все домены.
| Модель (таблица) | Назначение | Ключевые поля | Ограничения и индексы |
|---|---|---|---|
StateTransition (state_transitions) | append-only журнал переходов | subjectType: SubjectType, subjectId, fromState, toState, actorType: ActorType, actorUserId, actorRole, basisType, basisId, reason, metadata | @@index([subjectType, subjectId, createdAt]); нет updatedAt и удаления |
OutboxEvent (outbox_events) | исходящие события шины | eventType (= routing key, например milestone.status.changed), aggregateType, aggregateId, payload, publishedAt, attempts | @@index([publishedAt, createdAt]) под запрос relay |
QueueItem (queue_items) | задача рабочего места | type: QueueItemType, targetRole: PlatformRole, subjectType/subjectId, projectId (денормализация для скоупинга), status: QueueItemStatus, dueAt, overdueMarkedAt, assigneeUserId, closedByUserId, resultTransitionId | @@index([targetRole, status, createdAt]), @@index([subjectType, subjectId]) + частичный индекс дедупа |
Timer (timers) | отложенное срабатывание | kind: TimerKind, subjectType/subjectId, firesAt, firedAt, cancelledAt | @@index([firesAt, firedAt, cancelledAt]) |
Notification (notifications) | уведомление пользователю | type (= eventType источника), subjectType/subjectId, requiresAction, payload, readAt | @@index([userId, readAt, createdAt]) |
В
QueueItemнет денежных полей — оператор видит задачу, но не сумму. Дефолтная сортировка очереди — возраст (createdAt), поэтому индекс включает его третьим ключом. Журнал переходов пишет только библиотека машины состояний.
13. Консьерж
Заголовок раздела «13. Консьерж»| Модель (таблица) | Назначение | Ключевые поля | Ограничения и индексы |
|---|---|---|---|
ConciergeThread (concierge_threads) | обращение в поддержку | openedByUserId, side: DealSide? (null = вне контекста сделки), projectId?, subject, status: ConciergeStatus | @@index([status, createdAt]) |
ConciergeMessage (concierge_messages) | сообщение обращения | authorUserId, authorRole (FOUNDER/TEAM/OPERATOR/ADMIN), body | thread → Restrict, только createdAt |
14. Интеграция трекера
Заголовок раздела «14. Интеграция трекера»| Модель (таблица) | Назначение | Ключевые поля | Ограничения |
|---|---|---|---|
TrackerLink (tracker_links) | связь сущности платформы с объектом внешнего трекера | subjectType (PROJECT/MILESTONE/TEAM), subjectId, externalType (workspace/board/member), externalId | @@unique([subjectType, subjectId, externalType]) |
| Enum | Значения | Где используется |
|---|---|---|
RoleType | CUSTOMER, EMPLOYEE, ADMIN, OPERATOR, ARBITER | Role.type |
WorkType | FULL_TIME, PART_TIME, CONTRACT, FREELANCE | EmployeeProfile.workType |
PortfolioLinkType | GITHUB, DEMO, PROD, OTHER | PortfolioLink.type |
LocationType | REGION, SUBREGION, COUNTRY, CITY | Location.type |
ProjectStatus | DRAFT, AI_CONSULTATION, SPEC_READY, TEAM_MATCHING, TEAM_PROPOSED, CONTRACTING, ACTIVE, COMPLETED, DISPUTED, CANCELLED | Project.status |
ContractingStage | PLAN_DRAFTING, PLAN_INTERNAL_REVIEW, KYC_PENDING, PLAN_SENT, SIGNING | Project.contractingStage |
ProjectCategoryType | WEB_DEVELOPMENT, MOBILE_DEVELOPMENT, DESIGN, MARKETING, COPYWRITING, DATA_SCIENCE, DEV_OPS, QA_TESTING, OTHER | Project.category |
WalriderMessageRole | USER, ASSISTANT | WalriderMessage.role |
TeamStatus | ONBOARDING, ACTIVE, SUSPENDED, ARCHIVED | Team.status |
TeamMemberRole | ADMIN, EDITOR, VIEWER | TeamMember.role, TeamInvitation.role |
TeamMemberStatus | ACTIVE, REMOVED | TeamMember.status |
InvitationStatus | PENDING, ACCEPTED, DECLINED, EXPIRED, REVOKED | TeamInvitation.status |
AuthorType | AI, HUMAN | Specification.authorType |
SpecificationStatus | DRAFT, PROPOSED, ACCEPTED, SUPERSEDED | Specification.status |
PlanVersionStatus | DRAFT, INTERNAL_REVIEW, SENT, ACCEPTED, REJECTED, SUPERSEDED | PlanVersion.status |
MilestoneStatus | PLANNED, PLAN_APPROVED, FUNDED, IN_PROGRESS, SUBMITTED, FOUNDER_APPROVED, VERIFIED, PAID, REJECTED, DISPUTED | Milestone.status |
CriterionState | UNCHECKED, MET, DISPUTED, CONFIRMED | AcceptanceCriterion.state |
ArtifactType | FILE, LINK, DEMO | Artifact.type |
ArtifactSource | PLATFORM, TRACKER | Artifact.source |
ReviewVerdict | CRITERIA_UNMET_RETURN, CRITERIA_MET_NEW_SCOPE, ESCALATED_TO_ARBITRATION | AcceptanceReview.verdict |
DealSide | FOUNDER, TEAM | Party.side, Contract.side, Dispute.initiatorSide, DisputePosition.side, Termination.initiatorSide, ConciergeThread.side |
KycState | NOT_REQUIRED, PENDING, SUBMITTED, APPROVED, REJECTED | Party.kycState |
PartyDocumentStatus | UPLOADED, APPROVED, REJECTED | PartyDocument.status |
ContractStatus | DRAFT, SENT_FOR_SIGNING, SIGNED, TERMINATED | Contract.status |
OfferState | SENT_TO_TEAM, TEAM_ACCEPTED, DECLINED_BY_TEAM, ACCEPTED_BY_FOUNDER, DECLINED_BY_FOUNDER, WITHDRAWN | Offer.state |
PayoutAccountState | NOT_STARTED, PENDING, RESTRICTED, ACTIVE | PayoutAccount.state |
HoldState | PENDING_IN, HELD, RELEASING, RELEASED, REFUNDED, SPLIT | Hold.state |
SettlementBasis | DUAL_APPROVAL, REVIEW_VERDICT, ARBITER_DECISION, TERMINATION_ASSESSMENT | Settlement.basisType |
SettlementStatus | PENDING, EXECUTED, FAILED | Settlement.status |
WebhookProvider | STRIPE, SIGNING, TRACKER | WebhookEvent.provider |
WebhookEventStatus | RECEIVED, PROCESSED, SKIPPED_DUPLICATE, FAILED | WebhookEvent.status |
DisputeState | OPENED, POSITIONS_GATHERING, UNDER_ARBITRATION, RESOLVED, ESCALATED_LCIA | Dispute.state |
DisputeOutcome | TEAM_FAVOR, FOUNDER_FAVOR, PARTIAL | Dispute.outcome |
TerminationState | REQUESTED, UNDER_REVIEW, EXECUTED, WITHDRAWN | Termination.state |
TerminationBasis | TEAM_FAULT, FOUNDER_INITIATIVE, TEAM_INITIATIVE, DISPUTE_DECISION | Termination.basis |
SubjectType | PROJECT, MILESTONE, HOLD, CONTRACT, DISPUTE, TERMINATION, OFFER, TEAM, PAYOUT_ACCOUNT, PLAN_VERSION, CONCIERGE_THREAD | StateTransition, OutboxEvent, QueueItem, Timer, Notification, TrackerLink |
ActorType | USER, SYSTEM, TIMER | StateTransition.actorType |
PlatformRole | OPERATOR, ADMINISTRATOR, ARBITER | QueueItem.targetRole |
QueueItemStatus | OPEN, IN_PROGRESS, DONE, CANCELLED | QueueItem.status |
QueueItemType | PLAN_APPROVAL, SUBMISSION_VERIFICATION, CONCIERGE_FIRST_LINE, ACCEPTANCE_REVIEW, PLAN_MEDIATION, TEAM_MATCHING, PRICE_REVIEW, KYC_DOCUMENTS, TERMINATION_ASSESSMENT, TEAM_ONBOARDING, ESCALATED_INQUIRY, BUDGET_GATE_REVIEW, FOUNDER_SILENCE_DECISION, SETTLEMENT_EXECUTION, DISPUTE_ARBITRATION | QueueItem.type |
TimerKind | FOUNDER_SILENCE, DISPUTE_POSITION_WINDOW, TERMINATION_OBJECTION_WINDOW, ENVELOPE_EXPIRY, MILESTONE_DEADLINE, SLA_TARGET, REMINDER | Timer.kind |
ConciergeStatus | OPEN, ESCALATED, RESOLVED, CLOSED | ConciergeThread.status |
Два множества имён платформенной роли.
RoleTypeнесётADMIN— это значение едет в claim’еplatformRoleadmin-токена.PlatformRoleнесётADMINISTRATOR— оно типизируетQueueItem.targetRole. Расхождение намеренное; мост между множествами — картаPLATFORM_ROLE_BY_ROLE_TYPEвlibs/apis/shared/src/lib/constants/roles.constants.ts, и любой код обязан ходить через неё, а не сравнивать строки.
Соответствие значений SubjectType и союзов каталога событий api-core-domain-events закреплено
unit-тестом apps/core-api/prisma/schema-enums.spec.ts:
расхождение хотя бы в одном значении роняет прогон nx test core-api.
Точки расширения
Заголовок раздела «Точки расширения»Добавить поле или модель: правится schema.prisma, затем nx run core-api:prisma:migrate:dev
(новая миграция поверх baseline’а) и nx run core-api:prisma:schema:generate. Обновляются
соответствующий раздел этой страницы и строка в migrations.
Добавить значение в enum: значение в schema.prisma + миграция. Если enum участвует в каталоге
событий (SubjectType), союз в api-core-domain-events правится в том же коммите — иначе падает
schema-enums.spec.ts.
Добавить таблицу в сделочную цепочку: внешние ключи объявляются onDelete: Restrict, а имя
таблицы добавляется в DEAL_CHAIN_TABLES теста внешних ключей — иначе тест не даст новому ключу
проехать незамеченным.
Добавить инвариант, который Prisma не выражает: raw SQL в новой миграции (не в baseline —
он уже применён на окружениях) плюс интеграционный тест рядом с существующими в
apps/core-api-e2e/src/schema/.