Проверенные версии плана — дизайн-док
Актуально на: 2026-08-10
Статус: реализовано (все четыре этапа) · Дата: 2026-08-10
Документ из двух частей. Часть I — продуктовая (зачем это и как выглядит для пользователя). Часть II — техническая (модель, алгоритмы, точки интеграции, этапы работ).
Часть I. Продукт
1. Зачем это
Сегодня каждая публикация в маркетплейсе — это полный круг модерации: админ открывает план, изучает его целиком, оставляет пометки, одобряет. Правка описания или обложки уже одобренного листинга запускает круг заново. При этом никакой памяти о том, что именно было одобрено, у системы нет: после одобрения автор может изменить план как угодно, и покупатели получат непроверенную версию.
Вводим понятие проверенной версии плана:
- админ, завершая ревью, ставит плану отметку «проверено»;
- в этот момент система запоминает полный снимок плана — модель, текст БП, приложенные файлы;
- пока отметка свежая, автор публикует план в маркетплейсе без ревью — сразу на витрину;
- как только автор меняет содержимое плана, отметка становится устаревшей, и для новой публикации (или обновления продаваемой версии) нужно снова отправить план на проверку;
- покупателю всегда достаётся одобренная версия из снимка, а не текущее состояние плана продавца.
2. Три состояния отметки
| Состояние | Когда возникает | Что можно |
|---|---|---|
| Проверено | админ завершил ревью с отметкой | публикация без ревью; версия продаётся |
| Устарела | автор изменил содержимое плана после проверки | публикация только через ревью; на витрине продолжает продаваться прежняя одобренная версия |
| Отозвана | админ снял отметку вручную | как будто отметки не было |
Отметка не удаляется при правках, а устаревает. Снимок одобренной версии сохраняется, поэтому:
- в панели ревью виден дифф «что автор изменил с момента проверки» — повторный круг сводится к просмотру дельты, а не всего плана заново;
- админ может продлить отметку одной кнопкой, если изменения косметические (поправленная опечатка не должна стоить полного круга модерации);
- если автор откатил правки и содержимое снова совпало с одобренным, отметка возвращается в состояние «проверено» сама.
3. Публикация без ревью
Есть свежая отметка → нажатие «Опубликовать» сразу выводит листинг на витрину
(APPROVED), очередь /reviews его не видит. Правки карточки (описание,
обложка, галерея, цена) у такого листинга тоже не открывают заявку.
Нет отметки или она устарела → всё как сейчас: PENDING_REVIEW, заявка в
очередь, решение админа.
Витрину всё равно смотрят — но постфактум. Отметка покрывает план, а описание, обложку и галерею автор задаёт сам и меняет когда угодно. Поэтому публикация по отметке и каждая последующая правка карточки заводят не блокирующую заявку «Витрина»: листинг остаётся в продаже, а команда видит его в очереди и решает — оставить или снять с витрины с причиной. Претензия к карточке не отменяет отметку у самого плана: поправив описание, автор публикуется снова без полного круга ревью.
Редакционная публикация (isEditorial, только админ) выпускает версию плана
автоматически: админ публикует сам, значит сам и заверяет. Отдельного
исключения из общей логики больше нет.
4. Версии и обновления
Каждая выданная отметка — это версия плана: v1, v2, v3. У листинга есть указатель, какая версия сейчас продаётся; в заказе фиксируется, какую версию получил покупатель.
Когда автор доработал план и получил новую отметку, он может сделать новую версию продаваемой. Держателям старых версий можно предложить обновление. Условия автор задаёт при публикации:
- обновления не предусмотрены — покупатель получает свою версию навсегда;
- бесплатно — новые версии выдаются без оплаты;
- за доплату — фиксированная сумма, не больше цены самого плана.
У бесплатного плана (price = 0) обновления принудительно бесплатны. У
эксклюзивной передачи обновлений нет по определению: проект физически уходит
покупателю, обновлять нечего.
Обновление выдаётся отдельным новым проектом, а не накатывается на копию покупателя. Слить авторские правки с правками покупателя нельзя — он месяц адаптировал план под себя. Старая копия остаётся нетронутой, человек сам решает, что переносить.
Даже бесплатное обновление не выдаётся автоматически: приходит уведомление и кнопка «Получить обновление». Иначе список проектов активного покупателя зарастёт копиями, которых он не просил.
5. Правило условий обновления: действует лучшее
Условия фиксируются в заказе на момент покупки, но в момент выдачи обновления берётся лучшее для покупателя из зафиксированного и текущего:
| В заказе | Сейчас у листинга | Покупатель получит |
|---|---|---|
| платно 900 | платно 500 | платно 500 — подешевело, применяем |
| платно 500 | платно 900 | платно 500 — подорожание не касается |
| платно 500 | бесплатно | бесплатно |
| бесплатно | платно 900 | бесплатно — навсегда |
| нет обновлений | платно 500 | платно 500 — право появилось, это улучшение |
| платно 500 | нет обновлений | платно 500 — право сохраняется |
Формально: «бесплатно» бьёт всё, иначе берётся минимальная из цен, «нет обновлений» считается бесконечностью.
Следствие: при смене цены ничего не нужно пересчитывать — ни массовых проходов по заказам, ни фоновых задач. Условия вычисляются в момент, когда покупатель открывает предложение об обновлении.
Кулдаун на смену цены (PRICE_CHANGE_COOLDOWN_HOURS) применяется
асимметрично: удешевление обновлений и переход в «бесплатно» — без
ограничений, подорожание и отключение обновлений — под общий кулдаун.
6. Что видит пользователь
Автор — на карточке проекта бейдж «Проверено · v2» или «Проверка устарела»; в панели ревью — статус отметки, дифф от одобренной версии, кнопка «Отправить на проверку»; в «Моих листингах» — «продаётся версия от 6 августа» и подсказка обновить продаваемую версию, если план изменился.
Админ — в панели ревью кнопки «Завершить с отметкой» / «Завершить без отметки», при устаревшей отметке — дифф и «продлить отметку»; в очереди видно, что план разошёлся с одобренной версией.
Покупатель — бейдж «Проверено PlanForge» и «Обновления: бесплатно» на карточке в каталоге; в «Моих покупках» — условия обновлений и кнопка получения, когда выходит новая версия.
Часть II. Техника
7. Модель данных
enum PlanVersionStatus {
VERIFIED // содержимое плана совпадает с одобренным
STALE // план изменён после проверки
REVOKED // отметка снята админом
}
model PlanVersion {
id String @id @default(cuid())
projectId String
version Int // порядковый номер в рамках проекта: 1, 2, 3…
requestId String? // заявка, по которой выдана отметка
issuedById String? // админ, выдавший отметку
contentHash String // общий отпечаток одобренного содержимого
stateHash String @default("") // покомпонентно: модель плана
docHash String @default("") // текст БП
filesHash String @default("") // приложенные файлы (пусто до этапа 3)
digestVersion Int @default(1)
snapshot Bytes? // gzip(JSON): state + planDoc + манифест файлов
snapshotBytes Int @default(0)
status PlanVersionStatus @default(VERIFIED)
summary String? // итог ревью, с которым выдана отметка
verifiedAt DateTime @default(now())
stalledAt DateTime?
revokedAt DateTime?
@@unique([projectId, version])
@@index([projectId, status])
}Почему отдельная таблица, а не поля на Project. Нужна история (кто, когда
и что именно одобрил) и хранение тяжёлого снимка отдельно от строки проекта,
которую читают все списки.
Почему Bytes + gzip, а не Json. JSON состояния жмётся в 8–10 раз, а
читаем мы снимок редко (ревью, выдача копии). Запросы внутрь снимка не нужны:
всё, по чему нужно искать, вынесено в колонки и в таблицу файлов.
Денормализации на Project нет. Активная версия ищется одним findFirst,
для списка проектов — одним findMany по projectId in (...), как уже сделано
для reviewStatus в app/(app)/projects/page.tsx.
Текущий отпечаток плана — отдельная строка ProjectContentDigest
(projectId, stateHash, docHash, filesHash, digestVersion). Он
обновляется по частям при каждой записи содержимого, и сравнение с одобренной
версией сводится к сверке трёх строк. Без него пришлось бы при каждом
сохранении собирать весь план из базы: сохранить одну часть и сравнить только
её нельзя — вторая могла разойтись раньше, и план ошибочно снова считался бы
проверенным.
Дополнительно:
enum ListingUpdatePolicy { NONE FREE PAID }
// PlanListing
soldVersionId String? // какая версия продаётся
updatePolicy ListingUpdatePolicy @default(NONE)
updatePrice Decimal? @db.Decimal(18, 2)
// Order — условия на момент покупки, снимком, а не ссылкой
versionId String?
updatePolicy ListingUpdatePolicy @default(NONE)
updatePrice Decimal? @db.Decimal(18, 2)
updatedFromOrderId String? // заказ-предшественник, если это обновление
// PlanVersionFile — манифест файлов версии (этап 3)
versionId String
contentHash String
title String
originalName String
kind String
sizeBytes IntМанифест файлов обязан быть отдельной SQL-таблицей: ссылки на блобы должны считаться запросом, внутрь gzip-снимка заглянуть нельзя.
Новые типы уведомлений: PLAN_VERIFIED, PLAN_VERIFICATION_STALE,
PLAN_UPDATE_AVAILABLE.
8. Digest: что считается изменением плана
Автосейв воркспейса срабатывает каждые 900 мс на любое изменение состояния,
включая сворачивание таблиц и переключение вкладок. Поэтому updatedAt как
признак «план изменился» не годится — отметку сбивало бы кликами по интерфейсу.
Признак — хеш содержательного среза.
stateHash = sha256(stableStringify(pick(state, PLAN_REVIEW_DATA_KEYS))) // без ключей *Ui
docHash = sha256(stableStringify(normalizePlanDoc(planDoc))) // без collapsed
filesHash = sha256(stableStringify(files)) // этап 3PLAN_REVIEW_DATA_KEYS и stableStringify уже есть в
lib/plan-review/sections.ts и lib/plan-review/snapshot.ts — переиспользуем,
не дублируем.
Отпечаток покомпонентный. Редактор текста БП сохраняется раз в 1,2 с;
пересобирать ради этого всю модель плана из базы было бы расточительно, поэтому
при записи хешируется только записанная часть, а остальные берутся из
ProjectContentDigest.
Часть, которой нет у одной из сторон, из сравнения исключается. Так снимок, снятый до появления манифеста файлов, продолжает сравниваться по модели и тексту: выкатка новой части не обнуляет выданные отметки.
Смена алгоритма не сбрасывает отметки. Если digestVersion у версии
отличается от текущей, её отпечаток пересчитывается из снимка и записывается
обратно — миграция происходит на лету, при первой же сверке.
Снимок шире digest: в digest входит только то, изменение чего должно
сбрасывать отметку, а в снимок — весь state целиком (включая настройки
оформления), текст БП и манифест файлов, потому что снимок разворачивается
покупателю.
9. Жизненный цикл отметки
Выдача — при завершении заявки в PATCH /api/plans/[id]/review:
kind = PLAN: появляетсяdecision.APPROVEзавершает ревью и выпускает версию,nullпросто закрывает заявку («разобрали, но не заверяем»),REJECTзакрывает с отказом.kind = LISTING: приAPPROVEверсия выпускается в той же транзакции, что и публикация листинга, и сразу становится продаваемой (soldVersionId).- Выпуск версии по PLAN-заявке — право
ADMIN: ревьюер может завершить заявку и отклонить, но не заверить. По LISTING-заявке версию выпускает тот, кто одобрил публикацию: это решение и так выводит план на витрину. - Отдельный ресурс
/api/plans/[id]/verification:GET— статус и дифф,POST— продление отметки админом,DELETE— отзыв.
Устаревание — refreshAfterPlanWrite(projectId, part, value) вызывается
после записи содержимого и только если у проекта есть отметка (у
подавляющего большинства проектов её нет → один SELECT):
saveProjectWorkspaceState(частьstate) — на каждом сохранении, включая лёгкий автосейв: он пишет ровно те же данные, и пропуск означал бы, что правка перед закрытием вкладки сохранится с действующей отметкой;PUT /api/plans/[id]/doc(частьdoc);- роуты документов проекта (часть
files).
Переход обратный: если отпечаток снова совпал (автор откатил правку), STALE
возвращается в VERIFIED.
Сверка не должна ронять сохранение: вызовы обёрнуты, ошибка сверки не мешает
записи плана. Статус меняется через updateMany с проверкой прежнего значения —
параллельные сохранения не переписывают результат друг друга.
Страховка. В момент публикации без ревью хеш пересчитывается всегда, не полагаясь на хук: публикация — необратимое действие, дешевле проверить лишний раз.
10. Публикация и продажа
POST /api/plans/[id]/listings: активная VERIFIED → листинг создаётся сразу
APPROVED, soldVersionId = эта версия, openListingReview не вызывается, в
ListingReview пишется запись «одобрено автоматически по проверке v2» (история
в «Моих листингах» не рвётся). Иначе — прежний путь через PENDING_REVIEW.
Так же ведут себя description, cover, images — через общий
applyListingSubmission в lib/plan-review/listing-review.ts: свежая отметка →
правка карточки не снимает листинг с витрины.
Два исключения, где отметка не даёт публиковаться:
resubmitвсегда идёт через модерацию. Отклонённый листинг — это осознанное решение команды (в том числе по витрине), и отметка плана его не отменяет: иначе автор возвращал бы снятый контент одной кнопкой, сколько угодно раз.- пока по плану открыта блокирующая заявка (
PLAN/LISTING), публикация отвечает 409 и с отметкой тоже — иначе план ушёл бы на витрину, а через час та же заявка завершилась бы отказом.
Проверка витрины — заявка kind = LISTING_CARD. Она не входит в
BLOCKING_REVIEW_KINDS, поэтому не занимает «место» единственной активной
заявки проекта и не показывается в панели ревью (доступ к плану она при этом
даёт: из очереди ведёт ссылка «Открыть план»). Решение принимается прямо в
очереди /reviews
(PATCH /api/reviews/card/[requestId]): APPROVE закрывает заявку, REJECT
переводит листинг в REJECTED с причиной и пишет запись в ListingReview.
Пока заявка открыта, новые правки не плодят строки в очереди, а копятся
поводами в её comment. Архивация листинга закрывает и её
(cancelListingReviews).
Редакционная публикация (isEditorial) выпускает версию сама: админ выкладывает
план и тем самым его заверяет, поэтому дальше работают общие правила, а не
отдельная ветка «без модерации».
lib/purchase-fulfillment.ts: fulfillCopy и clonePlanToUser разворачивают
снимок версии soldVersionId вместо текущего состояния продавца
(resolveSalePayload + restorePlanSnapshot). Это закрывает дыру «одобрился и
подменил» и попутно чинит потерю текста БП при покупке. Листинги без версии
(выставленные до перехода) продолжают отдавать текущее состояние. Реферальная
награда выдаётся тем же снимком, что и покупка.
Условия обновлений фиксируются в заказе в момент выдачи; их изменение —
PATCH /api/marketplace/listings/[id]/price вместе с ценой плана или отдельно,
с асимметричным кулдауном из раздела 5.
11. Файлы: контент-адресуемое хранилище
Раньше файл лежал по пути uploads/project-documents/<projectId>/<documentId>-<имя>,
а DELETE документа физически стирал его с диска. Снимок, сославшийся на такой
путь, оказался бы битым.
Хранение по хешу содержимого (lib/blob-store.ts):
- при загрузке считается sha256, файл кладётся в
uploads/blobs/<ab>/<cd>/<sha256>(запись через временный файл +rename, чтобы параллельная загрузка не отдала читателю недописанный блоб); ProjectDocument.contentHashссылается на содержимое,storageKeyу новых записей пуст и остаётся только у ещё не перенесённых;- удаление документа удаляет строку в БД; блоб стирается, только если на него не
ссылается ни один живой документ и ни один снимок (
PlanVersionFile); downloadиpreview-pdfрезолвят путь черезresolveProjectDocumentPath— он понимает оба способа, поэтому чтение работает и до миграции. Копии «с подменой» (model-copy) — производные файлы, они живут отдельно.
Побочные выгоды: одинаковые файлы у разных проектов лежат на диске один раз (при квоте 50 МБ на проект это заметно), копирование документов покупателю — вставка строк со ссылками на те же байты, а подмена приложения сбрасывает отметку.
Разовый перенос существующих файлов:
pnpm exec tsx scripts/migrate-documents-to-blobs.ts --dry
pnpm exec tsx scripts/migrate-documents-to-blobs.tsДокументы с пустым contentHash в отпечатке не участвуют: до переноса они не
влияют на отметку и не копируются покупателю, но и не сбрасывают ничего на
ровном месте.
12. Этапы
Этап 1 — версии и отметка. Сделано. Схема целиком (включая поля этапов 2 и
4, чтобы миграция была одна), lib/plan-review/plan-digest.ts,
plan-snapshot.ts, verification.ts, ресурс
app/api/plans/[id]/verification/route.ts, решение при завершении ревью,
устаревание при правках, блок отметки в панели ревью, бейдж на карточке
проекта, уведомления, скрипт бэкфилла.
Этап 2 — публикация без ревью. Сделано. Публикация и правки карточки по
отметке, выдача покупателю снимка одобренной версии вместе с текстом БП,
lib/marketplace-updates.ts (валидация условий и правило «действует лучшее»),
выбор политики обновлений на форме публикации, фиксация условий в заказе,
бейджи «Проверено PlanForge» и «Обновления бесплатно» в карточке листинга,
условия обновлений в «Моих покупках», продаваемая версия в «Моих листингах»,
не блокирующая проверка витрины (LISTING_CARD) с решением в очереди.
Этап 3 — файлы по хешу. Сделано. lib/blob-store.ts, PlanVersionFile,
ProjectDocument.contentHash, роуты выдачи через resolveProjectDocumentPath,
удаление с подсчётом ссылок, скрипт scripts/migrate-documents-to-blobs.ts,
файлы в отпечатке (filesHash) и в снимке, копирование документов покупателю.
Этап 4 — обновления. Сделано. Уведомление держателям прежних версий при
смене продаваемой версии (notifyUpdateAvailable), предложение и его условия в
«Моих покупках» (resolveUpdateOffer), заказ на обновление
(POST /api/purchase/[listingId]/update) — бесплатный выполняется сразу,
платный уходит в Robokassa тем же путём, что и покупка, — и выдача новой версии
отдельным проектом (fulfillUpdate).
Механика цепочки: позиция покупателя — его последний оплаченный заказ по
листингу, Order.updatedFromOrderId связывает обновление с предыдущим звеном
(поле уникально, поэтому дважды обновиться с одной точки нельзя; незавершённый
заказ — и PENDING, и сорвавшийся FAILED — переиспользуется). Тираж
обновление не расходует, а SOLD и снятие с витрины права купивших не
отбирают. Условия следующего обновления наследуются как
effectiveUpdateTerms(предыдущий заказ, листинг), а не берутся у листинга: иначе
покупатель, забравший обещанное бесплатное обновление, лишался бы права на
следующее, если автор успел его отключить.
Правка приложенных документов сбивает отметку только для перенесённых в блоб-хранилище файлов — см. выше.
13. Миграции
Синхронизация БД — ручным SQL в prisma/manual-migrations/ (схема
planforge), в коде только prisma generate. Скрипты пишутся идемпотентно
(IF NOT EXISTS, EXCEPTION WHEN duplicate_object), как
2026-08-06-listing-review-via-plan-review.sql.
Бэкфилл при выкатке этапа 1: планам, у которых сейчас есть APPROVED- или
SOLD-листинг, SQL заводит версию v1 — иначе после релиза все действующие
продавцы окажутся «непроверенными», а их листинги — без продаваемой версии.
Снимок в SQL не собрать, поэтому такие версии создаются пустыми: за отметку они
не считаются (isUsableVersion — проверка по contentHash и snapshotBytes,
поэтому работает и на «лёгких» выборках), бейдж проверки по ним не рисуется,
обновление не предлагается, продажа идёт прежним путём. Заполняет их скрипт:
pnpm exec tsx scripts/backfill-plan-versions.ts --dry # план без записи
pnpm exec tsx scripts/backfill-plan-versions.tsПорядок выкатки: применить
prisma/manual-migrations/2026-08-10-plan-verification.sql (он покрывает все три
этапа), затем прогнать два скрипта — сначала перенос файлов
(migrate-documents-to-blobs.ts), затем бэкфилл версий
(backfill-plan-versions.ts), чтобы в снимки сразу попали манифесты файлов.
До применения миграции интерфейс не ломается: список проектов и панель ревью
переживают отсутствие таблиц (запросы обёрнуты).