Предметная областьМодулиПроверенные версии плана — дизайн-док

Проверенные версии плана — дизайн-док

Актуально на: 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))                               // этап 3

PLAN_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), чтобы в снимки сразу попали манифесты файлов. До применения миграции интерфейс не ломается: список проектов и панель ревью переживают отсутствие таблиц (запросы обёрнуты).