[C28-21] Реестр подписанных документов в Postgres: getDocuments+searchDocuments на PG, newdeclined, авто-backfill #47

Merged
ant merged 21 commits from feat/C28-21-pg-signed-documents into dev 2026-05-31 06:21:19 +00:00
Owner

C28-21 — Реестр подписанных документов в Postgres

Документы кооператива теперь хранятся, отображаются и ищутся из единого Postgres-реестра, наполняемого ловлей blockchain-событий контракта soviet и разовым backfill'ом. Одним PR убираются: тяжёлый on-the-fly-сбор агрегатов (explorer/Mongo на каждый запрос) и OpenSearch (отдельный JVM-сервис) — поиск и список документов идут через один источник.

Что работает по-новому

  • ХранилищеSignedDocumentEntity (таблица signed_documents, одна строка = один документ, уникальный ключ (coopname, doc_hash)). Тонкость идентичности: doc_hash — сам документ, а hash включает наложенные подписи, поэтому у одного документа бывает несколько hash (версии по мере накопления подписей — напр. акт приёма-передачи: первая/вторая подпись дают разные hash при одном doc_hash). В реестре держим крайнюю версию (по block_num), иначе документ задвоился бы в списке; hash хранится как версия последней подписи (для фильтра/отображения). package — идентификатор ПРОЦЕССА и не уникален: один процесс содержит несколько РАЗНЫХ документов с разными подписантами (напр. обмен в marketplace — два newsubmitted с одним package); группирующая колонка, по ней в агрегат тянутся решение/акты/связи. В document_aggregate (jsonb) лежит готовый агрегат (statement/decision/acts/links вместе с контентом и PDF-бинарём base64) — payload для getDocuments. Рядом денормализованная проекция (full_title, content_text, signers_text, action, registry_id, username, hash, block_num, document_created_at, status).
  • getDocuments — read-path из PG: отдаёт готовый агрегат без сборки на лету и без обращений к explorer/Mongo. Контракт DTO (DocumentPackageAggregate) сохранён 1:1, агрегат собирается тем же DocumentPackageAggregator, что и раньше (теперь на этапе ingestion/backfill).
  • Эквивалентность getDocuments старому пути. Прежний DocumentService подмешивал в explorer-фильтр receiver: username (on-chain require_recipient): receiver=coopname → все документы кооператива, receiver=<пайщик> → его документы; Union-страница добавляла filter.document.hash. PG read-path теперь воспроизводит это точно: receiver===coopname → весь кооператив, иначе фильтр username=receiver (колонка = пайщик-субъект заявления), плюс регистронезависимый фильтр по hash. Проверено для всех трёх потребителей (Cooperative/ListOfDocuments, User/DocumentsPage, Union/ListOfCooperatives).
  • Фильтр вкладок (type) — по имени действия, не партиция по статусу. Прежде фильтровалось по имени soviet-действия: «Все входящие» (newsubmitted) = все документы (у каждого пакета есть submitted-действие, решённые тоже входят), «Только утверждённые» (newresolved) = решённые. PG read-path: newsubmitted → без фильтра статуса, newresolvedstatus=Resolved. Партиция по статусу скрыла бы решённые из вкладки «Все входящие» — исправлено.
  • Безопасность видимости. getDocuments под RolesGuard: обычному пайщику доступен только data.username==его_имя → фильтр сводит выборку к его документам; coop-wide (receiver=coopname) — лишь совету/председателю. findAggregates всегда фильтрует по config.coopname. Утечка чужих/всех документов пайщику структурно невозможна.
  • searchDocuments — честный SQL ILIKE по PG-реестру: signers_text (ФИО подписантов, приоритетно), full_title, content_text, username. Контракт SearchResultDTO сохранён. Скоуп по роли: член совета (chairman/member) ищет по всему кооперативу, обычный пайщик — только по своим документам (username=user.username). Так пайщик пользуется поиском на своей странице, но не видит чужих документов (заодно закрыта прежняя утечка — старый поиск был coop-wide для любого авторизованного).
  • Поиск по подписанту (ФИО). Денормализованная колонка signers_text собирает ФИО/наименования всех подписантов пакета (last/first/middle_name, short_name из signer_certificate всех частей агрегата — заявление/решение/акты/связанные) + их username. Это покрывает соподписантов (председатель/совет), которых нет в теле заявления (content_text).
  • Ingestion (SignedDocumentIngestionService) — подписка на внутреннюю шину @OnEvent(action::soviet::*), сборка готового агрегата через DocumentPackageAggregator:
    • newsubmitted/newresolved/newdeclined → пересобрать агрегат + выставить статус (с защитой от понижения);
    • newdecision/newact/newlink → досборка агрегата по сохранённому действию-носителю (source_action_data), статус не меняем.
  • Backfill (SignedDocumentBackfillService) — разовая идемпотентная миграция: скан действий newsubmitted/newresolved/newdeclined со скоупом {receiver: coopname} (ровно как прежний chairman-путь getDocuments — один трейс на документ, только наш кооператив) → сборка агрегатов → PG. Запускается автоматически один раз при пустом реестре (onApplicationBootstrap, детачем, не блокирует старт) либо принудительно по SIGNED_DOCS_BACKFILL_ON_BOOT=true.

Удаление OpenSearch (выполнено)

  • Удалены infrastructure/search/* (opensearch.service, search-registry.service, search-infrastructure.module) и application/search/services/search-event.service (старый индексатор по newsubmitted — заменён ingestion'ом + backfill'ом).
  • app.module: снят SearchInfrastructureModule. search.module: только SearchResolver (читает SIGNED_DOCUMENT_REPOSITORY из глобального TypeOrmModule).
  • package.json: убрана @opensearch-project/opensearch; pnpm-lock.yaml пересчитан.
  • docker-compose: удалён сервис opensearch + volume opensearch_data. .env-example: убраны OPENSEARCH_*.
  • system.interactor: features.search = true (поиск на PG доступен всегда, не зависит от OPENSEARCH_ENABLED).
  • Файлы удалены только из текущего состояния — в истории остаются.

cooptypes

Заведено registry-действие soviet newdeclined (contracts/soviet/actions/registry/newdeclined.ts + экспорт в Registry); статус declined теперь отслеживается ingestion'ом. dist пакета gitignored — пересобирается в CI (локально пересобран для проверки типов).

Зафиксированные решения (C28-21)

  • Готовый агрегат хранится в PG целиком (jsonb), не собирается на лету; бинарь (PDF base64) и html — внутри агрегата.
  • Источник ingestion — внутренняя событийная шина (её питает парсер), парсер не трогаем.
  • Статусы: resolved (основной) / declined (аннулирование) / submitted (почти не используется).
  • receiver-скоуп старого пути воспроизводится через колонку username (= пайщик-субъект заявления): для soviet-заявлений субъект совпадает с on-chain получателем уведомления.

Осознанные ограничения / следующий шаг

  • Эквивалентность getDocuments точна после backfill'а: на чистом реестре авто-backfill наполняет таблицу один раз при старте; до его завершения список частичный. Если реестр уже наполнялся прежним деплоем без колонок signers_text/новых полей — разово SIGNED_DOCS_BACKFILL_ON_BOOT=true.
  • Тесты (unit ingestion/precedence/repo; e2e: подписал → виден в списке и поиске; парность backfill) — в CI.

Размер: агрегат с PDF base64 для каждой части пакета может быть весомым; PG TOAST'ит/сжимает. При необходимости — оптимизация (вынести бинарь) отдельно.

Связано: C28-16 (зонтик composite-entity), C28-13 (parser2), C28-15 (event-driven), C28-18 (автосверка).

## C28-21 — Реестр подписанных документов в Postgres Документы кооператива теперь хранятся, **отображаются и ищутся из единого Postgres-реестра**, наполняемого ловлей blockchain-событий контракта `soviet` и разовым backfill'ом. Одним PR убираются: тяжёлый on-the-fly-сбор агрегатов (explorer/Mongo на каждый запрос) и OpenSearch (отдельный JVM-сервис) — поиск и список документов идут через один источник. ### Что работает по-новому - **Хранилище** — `SignedDocumentEntity` (таблица `signed_documents`, **одна строка = один документ**, уникальный ключ `(coopname, doc_hash)`). Тонкость идентичности: `doc_hash` — сам документ, а `hash` включает наложенные подписи, поэтому у одного документа бывает несколько `hash` (версии по мере накопления подписей — напр. акт приёма-передачи: первая/вторая подпись дают разные `hash` при одном `doc_hash`). В реестре держим **крайнюю версию** (по `block_num`), иначе документ задвоился бы в списке; `hash` хранится как версия последней подписи (для фильтра/отображения). `package` — идентификатор ПРОЦЕССА и **не уникален**: один процесс содержит несколько РАЗНЫХ документов с разными подписантами (напр. обмен в marketplace — два `newsubmitted` с одним `package`); группирующая колонка, по ней в агрегат тянутся решение/акты/связи. В `document_aggregate` (jsonb) лежит **готовый агрегат** (statement/decision/acts/links вместе с контентом и PDF-бинарём base64) — payload для `getDocuments`. Рядом денормализованная проекция (`full_title`, `content_text`, `signers_text`, `action`, `registry_id`, `username`, `hash`, `block_num`, `document_created_at`, `status`). - **`getDocuments`** — read-path из PG: отдаёт готовый агрегат без сборки на лету и без обращений к explorer/Mongo. Контракт DTO (`DocumentPackageAggregate`) сохранён 1:1, агрегат собирается тем же `DocumentPackageAggregator`, что и раньше (теперь на этапе ingestion/backfill). - **Эквивалентность `getDocuments` старому пути.** Прежний `DocumentService` подмешивал в explorer-фильтр `receiver: username` (on-chain `require_recipient`): `receiver=coopname` → все документы кооператива, `receiver=<пайщик>` → его документы; Union-страница добавляла `filter.document.hash`. PG read-path теперь воспроизводит это точно: `receiver===coopname` → весь кооператив, иначе фильтр `username=receiver` (колонка = пайщик-субъект заявления), плюс регистронезависимый фильтр по `hash`. Проверено для всех трёх потребителей (`Cooperative/ListOfDocuments`, `User/DocumentsPage`, `Union/ListOfCooperatives`). - **Фильтр вкладок (`type`) — по имени действия, не партиция по статусу.** Прежде фильтровалось по имени soviet-действия: «Все входящие» (`newsubmitted`) = все документы (у каждого пакета есть submitted-действие, решённые тоже входят), «Только утверждённые» (`newresolved`) = решённые. PG read-path: `newsubmitted` → без фильтра статуса, `newresolved` → `status=Resolved`. Партиция по статусу скрыла бы решённые из вкладки «Все входящие» — исправлено. - **Безопасность видимости.** `getDocuments` под `RolesGuard`: обычному пайщику доступен только `data.username==его_имя` → фильтр сводит выборку к его документам; coop-wide (`receiver=coopname`) — лишь совету/председателю. `findAggregates` всегда фильтрует по `config.coopname`. Утечка чужих/всех документов пайщику структурно невозможна. - **`searchDocuments`** — честный SQL `ILIKE` по PG-реестру: `signers_text` (ФИО подписантов, приоритетно), `full_title`, `content_text`, `username`. Контракт `SearchResultDTO` сохранён. **Скоуп по роли**: член совета (`chairman`/`member`) ищет по всему кооперативу, обычный пайщик — **только по своим документам** (`username=user.username`). Так пайщик пользуется поиском на своей странице, но не видит чужих документов (заодно закрыта прежняя утечка — старый поиск был coop-wide для любого авторизованного). - **Поиск по подписанту (ФИО).** Денормализованная колонка `signers_text` собирает ФИО/наименования всех подписантов пакета (`last/first/middle_name`, `short_name` из `signer_certificate` всех частей агрегата — заявление/решение/акты/связанные) + их username. Это покрывает соподписантов (председатель/совет), которых нет в теле заявления (`content_text`). - **Ingestion** (`SignedDocumentIngestionService`) — подписка на внутреннюю шину `@OnEvent(action::soviet::*)`, сборка готового агрегата через `DocumentPackageAggregator`: - `newsubmitted`/`newresolved`/`newdeclined` → пересобрать агрегат + выставить статус (с защитой от понижения); - `newdecision`/`newact`/`newlink` → досборка агрегата по сохранённому действию-носителю (`source_action_data`), статус не меняем. - **Backfill** (`SignedDocumentBackfillService`) — разовая идемпотентная миграция: скан действий `newsubmitted`/`newresolved`/`newdeclined` со скоупом `{receiver: coopname}` (ровно как прежний chairman-путь `getDocuments` — один трейс на документ, только наш кооператив) → сборка агрегатов → PG. Запускается **автоматически один раз при пустом реестре** (`onApplicationBootstrap`, детачем, не блокирует старт) либо принудительно по `SIGNED_DOCS_BACKFILL_ON_BOOT=true`. ### Удаление OpenSearch (выполнено) - Удалены `infrastructure/search/*` (`opensearch.service`, `search-registry.service`, `search-infrastructure.module`) и `application/search/services/search-event.service` (старый индексатор по `newsubmitted` — заменён ingestion'ом + backfill'ом). - `app.module`: снят `SearchInfrastructureModule`. `search.module`: только `SearchResolver` (читает `SIGNED_DOCUMENT_REPOSITORY` из глобального `TypeOrmModule`). - `package.json`: убрана `@opensearch-project/opensearch`; `pnpm-lock.yaml` пересчитан. - `docker-compose`: удалён сервис `opensearch` + volume `opensearch_data`. `.env-example`: убраны `OPENSEARCH_*`. - `system.interactor`: `features.search = true` (поиск на PG доступен всегда, не зависит от `OPENSEARCH_ENABLED`). - Файлы удалены только из текущего состояния — в истории остаются. ### cooptypes Заведено registry-действие soviet **`newdeclined`** (`contracts/soviet/actions/registry/newdeclined.ts` + экспорт в `Registry`); статус `declined` теперь отслеживается ingestion'ом. `dist` пакета gitignored — пересобирается в CI (локально пересобран для проверки типов). ### Зафиксированные решения (C28-21) - Готовый агрегат хранится в PG целиком (jsonb), не собирается на лету; бинарь (PDF base64) и html — внутри агрегата. - Источник ingestion — внутренняя событийная шина (её питает парсер), парсер не трогаем. - Статусы: `resolved` (основной) / `declined` (аннулирование) / `submitted` (почти не используется). - `receiver`-скоуп старого пути воспроизводится через колонку `username` (= пайщик-субъект заявления): для soviet-заявлений субъект совпадает с on-chain получателем уведомления. ### Осознанные ограничения / следующий шаг - Эквивалентность `getDocuments` точна **после backfill'а**: на чистом реестре авто-backfill наполняет таблицу один раз при старте; до его завершения список частичный. Если реестр уже наполнялся прежним деплоем без колонок `signers_text`/новых полей — разово `SIGNED_DOCS_BACKFILL_ON_BOOT=true`. - Тесты (unit ingestion/precedence/repo; e2e: подписал → виден в списке и поиске; парность backfill) — в CI. Размер: агрегат с PDF base64 для каждой части пакета может быть весомым; PG TOAST'ит/сжимает. При необходимости — оптимизация (вынести бинарь) отдельно. Связано: C28-16 (зонтик composite-entity), C28-13 (parser2), C28-15 (event-driven), C28-18 (автосверка).
claude added 1 commit 2026-05-29 08:32:50 +00:00
[C28-21][@ant] feat(controller): реестр подписанных документов в Postgres — ingestion + backfill
Typecheck / desktop (pull_request) Successful in 14m18s
Typecheck / controller (pull_request) Successful in 13m30s
f5ff2a7b10
Фундамент C28-21: PG-проекция подписанных документов (таблица signed_documents),
наполняемая ловлей blockchain-событий soviet с внутренней шины и разовым backfill'ом.
Аддитивно — OpenSearch и резолверы (searchDocuments/getDocuments) пока не трогаются.

- SignedDocumentEntity (signed_documents): package/hash/coopname/username/status,
  full_title/content_text/html/pdf(bytea), document_aggregate(jsonb), meta, block_num.
- SignedDocumentRepository (+SIGNED_DOCUMENT_REPOSITORY) + TypeORM-реализация (upsert/setStatus/search ILIKE).
- SignedDocumentIngestionService: @OnEvent(action::soviet::{newsubmitted,newresolved,newdeclined})
  -> идемпотентный upsert с защитой от понижения статуса; контент из factory-Mongo.
- SignedDocumentBackfillService: разовый идемпотентный скан действий (env-gate SIGNED_DOCS_BACKFILL_ON_BOOT).
- Регистрация в TypeOrmModule + app.module.

newdeclined пока не экспортирован как registry-action в cooptypes — подписка по литералу на будущее.
Следующая итерация: перевод searchDocuments/getDocuments на PG + снос OpenSearch + тесты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude added 1 commit 2026-05-29 08:52:53 +00:00
[C28-21][@ant] feat: getDocuments+searchDocuments на Postgres, newdeclined в cooptypes, авто-backfill агрегатов
Typecheck / desktop (pull_request) Successful in 13m43s
Typecheck / controller (pull_request) Successful in 13m14s
d34303a5c4
Вторая итерация C28-21 — сервис функционирует по-новому из PG:

- cooptypes: заведено registry-действие soviet `newdeclined` (+ экспорт в Registry); отслеживается ingestion'ом.
- Реестр хранит ГОТОВЫЙ агрегат пакета (document_aggregate jsonb) + денормализованную проекцию
  (full_title/content_text/action/registry_id/username/block_num/document_created_at) для поиска и фильтра.
- Ingestion собирает агрегат через DocumentPackageAggregator на 6 событиях soviet:
  newsubmitted/newresolved/newdeclined (статус) + newdecision/newact/newlink (досборка по source_action_data).
- getDocuments — read-path из PG: отдаём готовые агрегаты, фильтр по статусу(type)/действию/пайщику/блокам,
  без обращений к explorer/Mongo на лету.
- searchDocuments — SQL ILIKE по PG-реестру (контракт SearchResultDTO сохранён).
- Backfill запускается автоматически РАЗОВО при пустом реестре (onApplicationBootstrap, детачем) или
  принудительно по SIGNED_DOCS_BACKFILL_ON_BOOT=true; идемпотентен по (coopname, package).

OpenSearch-инфра пока оставлена (снос — отдельный финальный шаг). cooptypes/dist gitignored — пересобирается в CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude changed title from [C28-21] Реестр подписанных документов в Postgres — ingestion + backfill (фундамент) to [C28-21] Реестр подписанных документов в Postgres: getDocuments+searchDocuments на PG, newdeclined, авто-backfill 2026-05-29 08:53:58 +00:00
claude added 1 commit 2026-05-29 09:24:19 +00:00
[C28-21][@ant] feat(controller): денормализация ФИО подписантов (signers_text) + поиск по подписанту
Typecheck / desktop (pull_request) Successful in 13m28s
Typecheck / controller (pull_request) Successful in 13m12s
4d1b3b87c1
Поиск по документам — в основном по фамилии подписанта, а не username. content_text (html заявления)
этого не покрывает: соподписанты (председатель/совет) подписывают решение/акты, а не тело заявления,
плюс зависимость от текста шаблона. ФИО уже присутствуют в агрегате — в signer_certificate каждой подписи
(DocumentAggregator резолвит их из учётных данных подписанта).

- Новая колонка signed_documents.signers_text — ФИО/наименования всех подписантов пакета
  (last/first/middle_name физлиц-ИП и short_name организаций из signer_certificate всех частей
  агрегата: заявление/решение/акты/связанные) + их username.
- Заполняется на ingestion и backfill из готового агрегата (collectSignersText).
- searchDocuments ILIKE теперь покрывает signers_text (приоритетно), full_title, content_text, username.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude added 1 commit 2026-05-29 11:30:06 +00:00
[C28-21][@ant] fix(controller): getDocuments — точная эквивалентность read-path (receiver-скоуп + фильтр по hash)
Typecheck / desktop (pull_request) Successful in 13m35s
Typecheck / controller (pull_request) Successful in 13m6s
aeeb9de380
Старый getDocuments через DocumentService подмешивал в explorer-фильтр `receiver: username`
(on-chain require_recipient): receiver=coopname отдавал все документы кооператива, receiver=<пайщик>
— его документы; Union-страница добавляла filter.document.hash. PG read-path эти ключи игнорировал
(искал несуществующий data.username) — личная страница пайщика показала бы все документы кооператива.

- interactor: extractReceiverScope (receiver===coopname → весь кооператив; иначе фильтр username=receiver,
  т.к. колонка username = пайщик-субъект заявления) + extractHashFilter (filter.document.hash).
- repository: новый параметр hash в SignedDocumentListParams; фильтр UPPER(d.hash)=UPPER(:hash)
  (фронт шлёт хэш в upper-case).

Набор документов getDocuments теперь совпадает со старым explorer-путём для всех трёх потребителей
(Cooperative/ListOfDocuments, User/DocumentsPage, Union/ListOfCooperatives).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-29 11:36:59 +00:00
[C28-21][@ant] refactor(controller): полное удаление OpenSearch — поиск и список документов целиком на Postgres
Typecheck / desktop (pull_request) Successful in 14m26s
Typecheck / controller (pull_request) Successful in 13m48s
33801798ed
Завершение перехода одним PR: searchDocuments и getDocuments уже читают PG-реестр подписанных
документов, OpenSearch стал мёртвым кодом — убираем его полностью.

- Удалены infrastructure/search/* (opensearch.service, search-registry.service,
  search-infrastructure.module) и application/search/services/search-event.service
  (старый индексатор по newsubmitted — заменён SignedDocumentIngestionService + backfill).
- app.module: снят SearchInfrastructureModule. search.module: только SearchResolver (читает
  SIGNED_DOCUMENT_REPOSITORY из глобального TypeOrmModule).
- package.json: убрана зависимость @opensearch-project/opensearch; pnpm-lock.yaml пересчитан.
- docker-compose: удалён сервис opensearch + volume opensearch_data.
- .env-example: убраны OPENSEARCH_* (поиск больше не конфигурируется через env).
- system.interactor: features.search = true (поиск на PG доступен всегда, не зависит от OPENSEARCH_ENABLED).

Файлы удалены только из текущего состояния (git rm) — в истории остаются.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-29 14:06:58 +00:00
Самоаудит видимости документов выявил две регрессии относительно прежнего explorer-пути:

1. Вкладка «Все входящие» (type=newsubmitted) теряла решённые документы. Прежний фильтр был по
   ИМЕНИ действия: у каждого пакета есть newsubmitted-действие → «Все входящие» = все документы
   (решённые показывались и тут, и во «Только утверждённые»). Партиция по статусу (newsubmitted→
   status=Submitted) скрывала решённые из дефолтной вкладки User/DocumentsPage. Фикс: newsubmitted →
   без фильтра статуса (все), newresolved → status=Resolved.

2. Backfill сканировал explorer с query={} — без скоупа. Explorer (cooparser) хранит трейсы по
   получателям; без receiver вернулись бы все трейсы каждого действия (soviet/пайщик) и, возможно,
   чужие кооперативы. Фикс: query={receiver: coopname} — ровно как прежний chairman-путь getDocuments
   (один трейс на документ, только наш кооператив).

Утечки не было и нет: RolesGuard разрешает обычному пайщику только data.username==его_имя, а фильтр
findAggregates ограничивает username; coopname-скоуп (весь кооператив) доступен лишь совету/председателю.
findAggregates всегда фильтрует по config.coopname.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-29 18:20:41 +00:00
[C28-21][@ant] fix(controller): ключ реестра — hash документа, а не package (package = процесс с N заявлений)
Typecheck / desktop (pull_request) Successful in 13m53s
Typecheck / controller (pull_request) Successful in 14m23s
b9be36d203
Самоаудит по замечанию: package — это идентификатор ПРОЦЕССА, в который может входить несколько
заявлений с разными подписантами. Доказано в контракте: marketplace/change.cpp шлёт ДВА newsubmitted
с одним package, но разными document и username (обмен: contribute+return). Прежний уникальный индекс
(coopname, package) + upsert-по-package затирал второе заявление первым → ПОТЕРЯ ДОКУМЕНТА и склейка
username. Старый explorer-путь (список по newsubmitted) показывал оба.

- Сущность: уникальный индекс теперь (coopname, hash) — одна строка = один документ-заявление;
  (coopname, package) стал НЕуникальным группирующим индексом.
- ingestAction ключуется по data.document.hash; смена статуса (newsubmitted/newresolved/newdeclined,
  все несут то же заявление — подтверждено createagenda/declinedoc/make_complete_document) матчит
  строку по hash.
- newdecision/newact/newlink несут package (не заявление) → пересобирают агрегаты ВСЕХ заявлений
  пакета (repository.findByPackage), сохраняя статус каждого.
- Репозиторий: upsert/getStatus по hash; findByPackage вместо getSourceActionData; убраны
  неиспользуемые setStatus/exists.

Также: searchDocuments закрыт гардом RolesGuard + @AuthRoles(['chairman','member']) — поиск по
документообороту кооператива только для совета (SearchDocumentsInput не содержит username, поэтому
правило «свои ресурсы» не открывает доступ обычному пайщику). Поиск пайщиком по своим документам
пока не предусмотрен.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-29 18:38:15 +00:00
Два уточнения по замечаниям:

1. Ключ реестра переведён с hash на doc_hash. doc_hash = идентичность документа; hash включает
   наложенные подписи, поэтому у ОДНОГО документа бывает несколько hash (версии по мере накопления
   подписей — напр. акт приёма-передачи: 1-я подпись → один hash, 2-я → другой, doc_hash тот же).
   При ключе по hash документ задваивался в списке (одна подпись / две подписи). Теперь:
   - уникальный индекс (coopname, doc_hash); hash и package — неуникальные индексы;
   - ingestAction ключуется по data.document.doc_hash и держит КРАЙНЮЮ версию: guard'ы «не понижать
     статус» и «не откатывать на меньший block_num» (getState отдаёт status+blockNum); крайняя версия
     перетирает предыдущую (тот же документ, больше подписей);
   - hash хранится как версия последней подписи (для фильтра Union по document.hash и отображения).
   package по-прежнему группирует разные документы процесса (обмен = разные doc_hash = разные строки).

2. searchDocuments вернул доступ пайщику, но со скоупом: член совета (chairman/member) ищет по всему
   кооперативу, обычный пайщик — ТОЛЬКО по своим документам (search scope username=user.username).
   Снял role-гард, добавил @CurrentUser; SearchResolver решает скоуп по роли. Заодно закрывает прежнюю
   утечку (старый поиск был coop-wide для любого авторизованного).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-29 20:07:51 +00:00
[C28-21][@ant] fix: block_num реестра = блок on-chain действия, не генерации
Typecheck / desktop (pull_request) Successful in 13m36s
Typecheck / controller (pull_request) Successful in 13m5s
33437c6b35
Ось упорядочивания версий подписи одного doc_hash должна мерить одну величину
на обеих сторонах guard'ов ingestAction. Раньше block_num хранился через
resolveBlockNum(meta.block_num, action.block_num) с приоритетом meta.block_num
(блок ГЕНЕРАЦИИ документа фабрикой), а incomingBlock в guard'ах — action.block_num
(блок ДЕЙСТВИЯ в цепи). Так как документ всегда генерируется до подачи в цепь
(meta.block_num <= action.block_num), защита от отката на старую версию подписи
(incomingBlock < existing.blockNum) и идемпотентность по блоку фактически не
срабатывали. Теперь приоритет — action.block_num, meta.block_num лишь fallback.

Заодно выправлены устаревшие комментарии интерфейса репозитория (ключ — doc_hash,
а не hash).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ant requested changes 2026-05-29 20:13:19 +00:00
@@ -0,0 +36,4 @@
*
* Сканирует все действия soviet (newsubmitted/newresolved/newdeclined) через explorer-источник,
* для каждого достаёт документ из factory-Mongo и складывает в Postgres-проекцию.
* Идемпотентен по (coopname, package) повторный прогон безопасен.
Owner

точно так?

точно так?
claude added 1 commit 2026-05-29 20:15:20 +00:00
[C28-21][@ant] docs: backfill идемпотентен по (coopname, doc_hash), не package
Typecheck / desktop (pull_request) Successful in 13m31s
Typecheck / controller (pull_request) Successful in 13m7s
43d9363800
Устаревшая формулировка в docstring backfill-сервиса: после перехода на ключ
doc_hash идемпотентность/уникальность реестра — по (coopname, doc_hash), а НЕ по
(coopname, package). package сознательно не уникален (один процесс = несколько
разных документов с разными подписантами), поэтому ключом записи он быть не может.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

Поправил: backfill идемпотентен по (coopname, doc_hash), не (coopname, package). package сознательно не уникален (один процесс = несколько разных документов), поэтому ключом записи быть не может — это и была неточность. Коммит 43d9363800.

Поправил: backfill идемпотентен по (coopname, doc_hash), не (coopname, package). package сознательно не уникален (один процесс = несколько разных документов), поэтому ключом записи быть не может — это и была неточность. Коммит 43d9363800e.
claude added 1 commit 2026-05-30 11:52:35 +00:00
[C28-21][@ant] fix: @Index по имени свойства packageHash, не db-колонки package
Typecheck / desktop (pull_request) Successful in 12m56s
Typecheck / controller (pull_request) Successful in 12m36s
c5272c7088
TypeORM @Index(['...']) принимает имена СВОЙСТВ сущности, а не db-колонок.
Колонка объявлена @Column({ name: 'package' }) packageHash, поэтому индекс
['coopname','package'] падал на старте: "Index contains column that is missing
in the entity (SignedDocumentEntity): package". Индекс по db-колонке `package`
строится корректно и через имя свойства packageHash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-30 12:07:32 +00:00
[C28-21][@ant] fix: парсинг meta.created_at формата DD.MM.YYYY HH:mm + устойчивый backfill
Typecheck / desktop (pull_request) Successful in 13m13s
Typecheck / controller (pull_request) Successful in 12m41s
cdb9cc8344
Документы хранят дату в локализованном формате `DD.MM.YYYY HH:mm` + отдельное
meta.timezone (так пишет фабрика @coopenomics/factory через moment.tz). Прежний
`new Date(meta.created_at)` этот формат не парсил → Invalid Date → колонка
timestamptz падала с "invalid input syntax ... 0NaN-NaN-NaN", и throw в одной
записи ронял весь прогон backfill. Парсим тем же moment.tz, что и фабрика
(timezone из меты, иначе config.timezone), невалидное → null. Плюс per-record
try/catch в backfill: одна битая запись логируется и пропускается, а не валит прогон.

Проверено вживую на voskhod: backfill завершён scanned=8 created=4 updated=4
skipped=0, document_created_at = 2026-05-28 07:53:00+00 (валидные даты, не null).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-30 12:30:58 +00:00
[C28-21][@ant] feat: выдача поиска документов — ФИО подписанта и чистый заголовок
Typecheck / desktop (pull_request) Successful in 12m57s
Typecheck / controller (pull_request) Successful in 12m36s
9427c204ab
Поиск показывал username («ant») и сырой full_title с хвостом ` - - дата.pdf`.
Приводим к виду реестра на главной:
- SearchResult.signer — ФИО подписанта-субъекта («Иванов Иван Иванович»), берётся
  из signer_certificate готового агрегата (подпись с signer===username, иначе первая;
  организация → short_name). Тот же источник, что у чипов подписей в реестре.
- full_title в выдаче поиска теперь = meta.title (чистое наименование), как в реестре;
  fallback — прежний full_title.
- Фронт DocumentSearchDialog: заголовок + чип ФИО (BaseBadge) + дата вместо строки
  «username · дата»; убран пустой highlights-блок.

Схема + zeus-клиент перегенерированы (generate-schema/generate-client + sdk build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-30 12:52:56 +00:00
[C28-21][@ant] style: убрать верхний отступ в выдаче поиска документов
Typecheck / desktop (pull_request) Successful in 13m3s
Typecheck / controller (pull_request) Successful in 12m36s
e877bd3eac
Большая пустая полоса между строкой поиска и первым результатом мешала —
снят верхний padding секции результатов (q-pt-none), список начинается ближе.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-30 14:34:57 +00:00
[C28-21][@ant] copy: срок рассмотрения заявления — до 30 дней вместо 24 часов
Typecheck / desktop (pull_request) Successful in 12m54s
Typecheck / controller (pull_request) Successful in 12m32s
9d183f3b64
В экране ожидания регистрации (WaitingRegistration) «до 24 часов» — неактуально:
реальный регламентный срок рассмотрения советом — до 30 дней (обычно решение
принимается за день-два). Поправили текст под фактический срок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-30 15:06:49 +00:00
[C28-21][@ant] feat: открытие документа из поиска через фильтр реестра
Typecheck / desktop (pull_request) Successful in 12m55s
Typecheck / controller (pull_request) Successful in 12m31s
f64a430bff
Клик по результату поиска вёл в никуда (openDocument был пуст) — найденный
документ нельзя было посмотреть. Теперь клик кладёт hash в ?document=<hash>
текущего роута; страница реестра читает его и сводит список к одному документу,
авто-раскрывая его. Переиспользуем готовый backend-фильтр filter.document.hash
(тот же путь, что у Union-страницы) — без изменений API и codegen.

- useDocumentRouteFilter/useDocumentNavigation: чтение и установка ?document.
- SearchedDocumentBanner: баннер «показан один документ» с возвратом к списку.
- Обе страницы реестра (совет + пайщик) фильтруют виджет по выбранному документу.
- Виджет реагирует на смену filter и форсит статус-агностичный newsubmitted при
  hash-фильтре (иначе на вкладке «только утверждённые» неутверждённый документ
  из поиска не нашёлся бы).
- DocumentsTable авто-раскрывает строку по expandHash (регистронезависимо).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-30 15:39:53 +00:00
[C28-21][@ant] feat: отдельная canon-страница документа вместо фильтра реестра
Typecheck / desktop (pull_request) Failing after 8m24s
Typecheck / controller (pull_request) Successful in 12m42s
0cbc76b018
Прошлый подход (фильтр реестра по ?document=<hash> + баннер) отклонён —
нужна полноценная отдельная страница документа с переходом назад и deep-link.

- DocumentDetailsPage: canon-страница по эталону карточки собрания (back-link
  под шапкой, имя документа в заголовок через setPageTitleOverride, скелетон,
  EmptyState; контент — ComplexDocument). Грузит документ сама по hash из роута,
  поэтому открывается и прямым переходом по ссылке.
- Роуты documents/:hash в обоих расширениях: document-details (совет) и
  user-document-details (пайщик), hidden — по аналогии с meet-details.
- DocumentModel.loadDocument(username, hash): точечная загрузка одного документа
  (статус-агностичный фильтр newsubmitted), не трогает состояние списка.
- Клик в поиске ведёт на страницу документа (useDocumentNavigation выбирает роут
  по рабочему столу), а не фильтрует реестр.
- Откат фильтр-подхода: удалены SearchedDocumentBanner и useDocumentRouteFilter,
  страницы реестра/виджет/таблица возвращены к baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-30 15:41:26 +00:00
[C28-21][@ant] fix: раздел документа выводить из имени роута, не из workspace
Typecheck / desktop (pull_request) Failing after 8m32s
Typecheck / controller (pull_request) Successful in 12m33s
7f65478dc5
activeWorkspaceName инициализируется асинхронно — при холодном переходе по
прямой ссылке на документ совета скоуп мог упасть в скоуп пайщика и документ
«не находился». Имя роута (document-details / user-document-details) доступно
сразу и детерминированно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-30 16:04:35 +00:00
[C28-21][@ant] feat: реестр документов на канон-карточки + липкая кнопка «назад»
Typecheck / desktop (pull_request) Failing after 8m31s
Typecheck / controller (pull_request) Successful in 12m49s
716eb4fe5a
Реестр документов переведён с таблицы на канон плоские строки (DocumentRow):
строка кликабельна и ведёт на отдельную страницу документа. Таблица с
инлайн-раскрытием оставлена только для Union-страницы (expand=true).

- DocumentCardsList: список DocumentRow (заголовок, дата, ФИО подписантов,
  скачивание в actions), скелетоны при загрузке, «Загрузить ещё». Статус-чип
  не показываем — в реестре всё подписано (канон stop-signal).
- ListOfDocumentsWidget: проп expand — карточки по умолчанию (совет/пайщик),
  таблица для Union (передаёт expand=true) — поведение Union не меняется.
- useDocumentNavigation перенесён из фичи поиска в entity Document
  (переиспользуют и поиск, и карточный список — чистые слои).
- DocumentDetailsPage: кнопка «назад» теперь в липком баре (sticky,
  top=--p-topbar-h), видна всегда при прокрутке документа.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-30 16:17:33 +00:00
[C28-21][@ant] fix: открытие документа из карточки по hash (подписанный), а не doc_hash
Typecheck / desktop (pull_request) Failing after 8m20s
Typecheck / controller (pull_request) Successful in 12m31s
42d3fc46e0
Карточка реестра вела на страницу документа, передавая rawDocument.hash — а это
doc_hash (хеш самого документа, без подписи). Бэкенд же ищет документ по колонке
hash (подписанный документ = содержимое + подпись): UPPER(d.hash)=UPPER(:hash).
Значения разные → getDocuments возвращал 0 → «Документ не найден».

Из поиска работало, потому что searchDocuments отдаёт именно hash (колонку), а не
doc_hash. Карточка теперь берёт тот же document.hash (= колонка hash в реестре).

Проверено по БД: фильтр по doc_hash → 0 строк, по document.hash → 1; у всех
записей document.hash == колонка hash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
claude added 1 commit 2026-05-31 05:37:15 +00:00
[C28-21][@ant] fix: icon в IWorkspaceRouteMeta опционален для hidden-роутов
Typecheck / desktop (pull_request) Successful in 13m52s
Typecheck / controller (pull_request) Successful in 13m12s
6e388e3196
Страницы deep-link (карточка документа, name=document-details/
user-document-details) помечены meta.hidden=true и в навигацию не
попадают — иконка им не нужна. Drawer рендерит q-icon под v-if='item.icon',
undefined безопасен. Убирает TS2741 'Property icon is missing'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ant merged commit bc989f47d1 into dev 2026-05-31 06:21:19 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: C9S/mono#47