Compare commits

..

61 Commits

Author SHA1 Message Date
Alex Ant abcd34f23d fix(capital): revert ошибочной замены GenerationConvertStatement → MoneyInvestStatement (#394) (#396)
PR #394 «починил» TS-ошибки coopback заменой типа `Cooperative.Registry.GenerationConvertStatement`
на `GenerationMoneyInvestStatement` в 3 файлах controller'а. Это семантически неверно:

- 1080 GenerationConvertStatement — заявление о трансляции паевого взноса
  (поля: project_hash, main_wallet_amount, blagorost_wallet_amount, to_wallet, to_blagorost, appendix_hash)
- 1020 GenerationMoneyInvestStatement — заявление о денежном паевом взносе по программе Генерация
  (совсем другой набор полей)

DTO BaseGenerationConvertStatementMetaDocumentInputDTO декларирует поля 1080, но `implements ExcludeCommonProps<action>`
где action = тип 1020 → TS2352 на as-cast в interactor, потому что Action'ы не пересекаются по полям.

Реальная причина исходных ошибок coopback после #392 — несвежий dist `@coopenomics/cooptypes`
на dev-узле (старое имя символа). Лечится пересборкой пакета, не переименованием ссылок.

Изменено (откат #394):
- generation-convert-statement-document.dto.ts:12 — `action = ...GenerationConvertStatement.Action`
- distribution-management.service.ts:56 — `registry_id: ...GenerationConvertStatement.registry_id`
- distribution-management.interactor.ts:48,66 — `Promise<...GenerationConvertStatement.Action>`

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:00:45 +05:00
Alex Ant 83cac58fca fix(controller): IS_UNIONED zod-парсер принимает string из .env (#395)
z.boolean().default(true) валится для переменной из .env, потому что
process.env всегда отдаёт строку: zod не приводит "true"/"false" к
boolean, в результате validateEnv падает с «IS_UNIONED: параметр не
установлен» и coopback не стартует, если в .env стоит IS_UNIONED=false
(стандартный dev-обход messenger-гейта, см. flow подключения партнёра).

Заменено на string().default('true').transform(v => v === 'true') —
сохранение прежнего default=true и поддержка string-форм из env.

Co-authored-by: coopops <coopos@coopenomics.world>
2026-05-18 12:40:57 +05:00
Alex Ant dbf46d88d0 fix(capital): подтянуть 3 ссылки на GenerationConvertStatement → MoneyInvestStatement (#394)
PR #392 переименовал Cooperative.Registry.GenerationConvertStatement в
GenerationMoneyInvestStatement в @coopenomics/document, но в controller
осталось 3 несинхронизированные ссылки:

- distribution-management.service.ts:56 — registry_id метода generation
- distribution-management.interactor.ts:48,66 — тип Action в return и as-cast
- generation-convert-statement-document.dto.ts:12 — type action в DTO

Coopback падал на ts-node compile (TS2551/2724), что блокировало старт
всего dev-stack после reboot dev-chain 2026-05-18.

Co-authored-by: coopops <coopos@coopenomics.world>
2026-05-18 12:29:32 +05:00
Alex Ant a0cb930b77 refactor(capital/convert): унифицировать шаблон 1080, убрать 1081/1082 (#392)
* refactor(capital/convert): унифицировать 1080 как универсальное заявление о конвертации, убрать 1081/1082

Шаблон 1080 (GenerationConvertStatement) уже технически универсален — содержит
обе суммы (main_wallet_amount/blagorost_wallet_amount) и условные блоки в
context. Шаблоны 1081 (GenerationToProjectConvertStatement) и 1082
(GenerationToCapitalizationConvertStatement) были недоделанными заглушками
без полей и нигде не подключены в UI.

Изменения:
- cooptypes: 1080 переименован GenerationToMainWalletConvertStatement →
  GenerationConvertStatement; title/description нейтральные. 1081/1082 удалены.
- factory: Template + Action 1080 переименованы; в Action добавлено
  super.formatAsset(...) для обеих сумм (как в Action 1020). 1081/1082 удалены.
- controller: DTO переименован; appendix_hash убран из generate-input и
  перенесён в signed-meta-input; добавлен enrich appendix_hash через
  AppendixRepository.findConfirmedByUsernameAndProjectHash в
  DistributionManagementInteractor.prepareGenerationConvertStatementData
  (по образцу InvestsManagementInteractor для 1020). Резолвер мутации
  переименован в capitalGenerateGenerationConvertStatement; убраны два
  резолвера 1081/1082. mutation-log-mapper обновлён.
- sdk: мутация переименована, две удалены, zeus regenerated.
- desktop: Distribution-фичи 1081/1082 удалены, 1080-фича переименована.
  ConvertSegment теперь шлёт project_hash + обе суммы (formatToEosioAsset) +
  to_wallet/to_blagorost; appendix_hash подтягивается на бекенде.
- controller schema.gql regenerated.

registry_id=1080 не меняется — on-chain контракт convertsegm не сверяет
registry_id, миграций БД/контракта не требуется.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(capital/convert): применить ревью — title «трансляция паевого взноса из программы Генерация»

По комментарию ревью в PR #392 (строка 37): принять доменный термин
«трансляция паевого взноса» (перенос между программами) вместо
«конвертация»; description согласован в том же стиле.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:29:12 +05:00
Alex Ant 4a4145c4ed feat(controller): is_server_init флаг в initSystem для provider-overwrite (#391)
* feat(docs-harness): onboarding 01..06 — визуальная цепочка регистрация → активация

Шесть сценариев visual docs-harness, покрывающих полный путь подключения
нового кооператива через провайдера Восход:

  01-register-coop                 — регистрация кооператива-клиента
  02-sign-and-submit               — подпись заявления о вступлении + PayInitial
  03-operator-approve              — chairman принимает заявку в реестре одобрений
  04-sign-connection-agreement     — Партнёр-1 видит ConnectionAgreementStepper
                                     (на текущем стенде получаем заглушку
                                     /signup, пока пайщик не принят)
  05-activate-from-registry        — оператор открывает карточку инстанса
                                     в provider-frontend, выбирает preset
  06-wait-instance-active          — pending → ACTIVE (overrideInstance
                                     для имитации финального статуса в шоте)

Обвязка:
  • lib/registrator-signup.mjs — переиспользуемый helper подписания.
  • lib/harness.mjs — расширенный dismissOnboardingDialogs (Положение ЦПП
    и связанные модалки chairman'а).
  • state/cooperatives/{,.gitkeep} — папка для фикстур; partner1.json
    (с приватным wif) игнорируется (.gitignore обновлён).

ОГРАНИЧЕНИЕ: в 06 финальный ACTIVE — это playwright-override JSON, а не
реальный POST /instances/activate с боевым Ansible до testnet300.coopenomics.world.
Реальный E2E (аренда VM на Hostkey + поднятие кооператива на домене)
будет следующим шагом Эпика 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(controller): is_server_init flag в initSystem для разблокировки provider-overwrite

Поле data.is_server_init: boolean (optional) в InitDTO + домейн-интерфейсе.
Если true (вызов от провайдера через server-secret) — coopback ставит
init_by_server=true безусловно, даже если до этого пользователь успел
заполнить визард первым (user-init). Это разблокирует ситуацию, когда
провайдер не успел вызвать initSystem до того как chairman открыл
/install — следующий callInitSystemMutation от провайдера перезапишет
organization_data и пометит её readonly для визарда.

Без флага сохраняется прежняя логика: первая инициализация — серверная,
повторная наследует флаг.

Инцидент 2026-05-18 на partner1: при первой установке provider вообще
не успел/не сходил в callInitSystemMutation, визард пользователя
проинициализировал систему как user-init (init_by_server=false), визард
2-го захода не предзаполнил форму. С этим фиксом следующий вызов
provider'а поднимет флаг и фикстура встанет на место.

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:40:49 +05:00
Alex Ant 00860b3b18 fix commit process
Build bootstrap container / build (push) Failing after 2m52s
2026-05-15 22:21:35 +05:00
Alex Ant 8fe9509e97 fix(capital/time-tracking): личные доли estimate + revert при decline (#387)
* chore(release): publish

* chore(release): publish

* ci: атомарный release.yaml, убрать workflow_run-связку (#366)

build-contracts + build-containers через workflow_run упёрлись в:
(а) default-branch caveat (новая логика не активна, пока не в main),
(б) `${{ github.event.workflow_run.head_sha }}` иногда пуст в YAML-
выражениях — описание см. в шаге Resolve tag, инцидент v2026.5.14
не дёрнул PRODUCTION_WEBHOOK_URL.

Замена — один `release.yaml` на push тэга `v*`: резолвит ветку через
`git branch --contains`, собирает контракты → пушит
`dicoop/contracts:<branch>`, собирает базу + сервисные образы →
пушит `dicoop/<svc>:<tag>`, шлёт webhook. Гонок нет by construction.

`build-contracts.yaml` остаётся только на push веток для CI-
обновления `dicoop/contracts:dev|testnet|main` без релизного тэга.
Триггер `tags: ['*']` и логика резолва ветки через --contains
оттуда удалены — это теперь забота release.yaml.

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* chore(release): publish

* chore(release): publish

* ci: tag-only триггеры для build-contracts и docs (#367)

build-contracts.yaml — только workflow_dispatch (ручная пересборка
`dicoop/contracts:<branch>` для отладки на dev-ноде). Тэги обрабатывает
release.yaml атомарно (контракты + контейнеры + webhook), отдельная
сборка по push'у в ветку только давала вторую параллельную сборку.

publish-docs.yaml и build-contracts-docs.yaml — на push:tags v* с
gate-job'ом, пропускающим только продакшн-тэги (без -alpha/-beta/-rc/
-test) на main. Раньше docs пересобирались на каждый push в
main/testnet/dev/reports/marketplace2 — впустую.

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* fix(capital/time-tracking): личные доли estimate, partial-split, revert при decline

Три бага в распределении билетов времени, вскрытые на прод-инциденте voskhod
(проект CC7-1 «Концепция», estimate=15 ч, 3 creators):

БАГ #1 — recalcDoneEstimatesForContributorProject раздавал «общий остаток пула»
(estimate − committed_total) / N всем creators поровну. Закоммитивший свою долю
получал её ещё раз, остальные — урезанную (15/3=5 → после committed 5 у одного
становилось 10/3=3.33 у каждого, включая того кто уже закоммитил).

Фикс: личная доля = estimate/N − собственный committed estimate. Введён общий
helper redistributeIssueEstimateEntries, который используют и applyExplicit-
EstimateToTimeEntries (force=true), и recalcDoneEstimates (force=false с
no-op оптимизацией если раскладка уже совпадает с планом).

БАГ #2 — commitTime при partial split (entry.hours > requested) создавал новую
committed-запись без entry_type и estimate_snapshot. По default'у БД сохраняла
её как entry_type='hourly', что ломало последующий recalc (он фильтрует только
entry_type='estimate'). Фикс: явно копировать entry_type и estimate_snapshot
из оригинального entry.

БАГ #3 — declineCommit / handleDeclineCommit меняли только commit.status в БД,
но не возвращали time-entries в is_committed=false. После отказа мастера часы
оставались в total_committed_hours и не возвращались в available_hours. Фикс:
новый метод revertEntriesForDeclinedCommit в TimeTrackingInteractor + методы
findCommittedByCommitHash / revertCommittedEntriesByCommitHash в TimeEntry-
Repository. После revert делается force=true redistribute для затронутых DONE
задач, чтобы доли вернулись в норму.

Покрытие тестами: 17 unit-тестов в time-tracking.interactor.spec.ts с
регрессионными сценариями под каждый из трёх багов плюс integration-сценарий
полного lifecycle CC7-1 (estimate → коммит → decline → revert).

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:02:36 +05:00
coopops 848566bd69 fix(file-storage): MinIO стартует только при заданном MINIO_ENDPOINT
Trigger Contracts Docs Deploy / trigger-coopenomics (push) Failing after 11s
Publish Docs / build-and-publish-docs (push) Failing after 18m20s
Раньше MINIO_ENDPOINT имел default http://minio:9000, и на проде без
minio-контейнера контроллер падал на bootstrap в HeadBucket с
getaddrinfo ENOTFOUND minio — Nest application не поднимался вообще.

Теперь MINIO_ENDPOINT optional без default; адаптер хранит enabled-флаг
по наличию endpoint и при отсутствии — onApplicationBootstrap логирует
warning и возвращается без сетевых вызовов. Любая попытка getBucket /
fetchObjectForReadProxy кидает InterFileStorageBackendUnavailableError
с понятным сообщением.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:30:48 +00:00
coopops aeb4efe2b6 fix(capital/commit): nullable description/meta в CommitOutputDTO + не дефолтить satisfaction в 5
Если syncCommit не дождался delta из блокчейна, interactor возвращает DB-only
entity, где description/meta остаются undefined — non-nullable GraphQL field
ломал ответ мутации capitalCreateCommit. Сделал оба поля nullable.

UI CreateCommitButton.vue: satisfaction_stars=0 по умолчанию, label "не указано"
пока пользователь не выбрал; блок contribution_feedback в payload только если
stars >= 1 или review_text непустой.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:43:12 +00:00
Alex Ant 7ef138ba19 feat(file-storage): универсальное файловое хранилище контура кооператива (MinIO/S3-портабельное) (#359)
Trigger Contracts Docs Deploy / trigger-coopenomics (push) Failing after 7s
Publish Docs / build-and-publish-docs (push) Failing after 17m11s
* [E59-2][@ant] feat(inter): InterFileStorage порт — типы, токен INTER_FILE_STORAGE и типизированные ошибки для универсального файлового хранилища контура кооператива

* [E59-3][@ant] feat(controller): MinIO-адаптер InterFileStoragePort, реестр бакетов, @UseBucket/@InjectBucket декораторы и FileStorageInfrastructureModule с forRoot/forFeature; 36 unit-тестов на адаптер, реестр, декоратор и HMAC-подписание

* [E59-4][@ant] feat(controller): HTTP-ручка GET /api/storage/:bucket/:key с HMAC-валидацией подписи и стримом из MinIO; fetchObjectForReadProxy на адаптере, controller registered в FileStorageInfrastructureModule.forRoot; 9 e2e-тестов через supertest на 200/403/404/502

* [E59-5][@ant] test(controller): integration suite против реального MinIO — 8 сценариев на полный цикл put/head/getReadUrl/GET/delete + ошибки лимитов/MIME/metadata + HMAC-роут 200/403/404; docker-compose рядом с тестами, npm run test:integration:file-storage с автодетектом доступности MinIO

* [E59-6][@ant] feat(controller,compose): MinIO в dev docker-compose, env-валидация и FileStorageInfrastructureModule.forRoot в app.module — контроллер на старте идемпотентно создаёт бакет coop-<coopname>; integration-тесты проходят против MinIO из dev compose

* [E59-6][@ant] docs(file-storage): краткий README для разработчиков расширений — пример @UseBucket/@InjectBucket/forFeature, операции, ошибки, env, как запускать тесты

---------

Co-authored-by: coopops <coopos@coopenomics.world>
2026-05-14 23:14:10 +05:00
Alex Ant e6880e149b Merge branch 'dev' of github.com:coopenomics/mono into dev
Build bootstrap container / build (push) Failing after 2m53s
Trigger Contracts Docs Deploy / trigger-coopenomics (push) Failing after 6s
Build contracts container / build (push) Failing after 9m8s
Publish Docs / build-and-publish-docs (push) Failing after 15m25s
2026-05-13 22:40:12 +05:00
coopops a9c1e1924a ci: workflow_dispatch fallback для build-containers
После переключения build-containers с `push: tags` на `workflow_run`
обнаружился разрыв в миграционный период: на default-ветке (main) лежит
старый build-containers (с `push: tags`), на dev/testnet — уже новый
(с `workflow_run`). Push релизного тэга на коммит из dev/testnet НЕ
триггерит:
- старый build-containers в main: GitHub читает workflow definition
  ИЗ КОММИТА тэга (где уже новая версия без `push: tags`);
- новый build-containers через workflow_run: триггер берётся из
  default-ветки, где ещё старая версия без `workflow_run`.

В итоге для тэгов v2026.5.13-alpha-2/-3 build-containers не запустился
вовсе.

Фикс: workflow_dispatch с input.tag — позволяет руками запустить
сборку+деплой для любого выпущенного тэга. Логика resolve_tag
поддерживает оба источника. Это снимает блокер до мержа в main.

Использование:
  gh workflow run "Build Docker Images" -f tag=v2026.5.13-alpha-3
2026-05-13 17:37:55 +00:00
Alex Ant 938c3ff042 chore(release): publish 2026-05-13 21:44:14 +05:00
Alex Ant cd9ab32f04 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-13 21:43:52 +05:00
coopops 47f616e980 fix(ci/build-contracts): резолвить branch при триггере по тэгу
После коммита 5735e1fc36 workflow стал триггериться на push тэгов,
но `Determine build mode and docker tag` использовал `github.ref_name`
напрямую, который для тэга = `v2026.5.13-alpha-2` → не матчит ни одну
из ветвей в `case` → workflow падает «Unsupported branch».

Фикс: для триггера по тэгу резолвим ветку через `git branch -r --contains
$SHA` (порядок: main → testnet → dev). Полная история нужна для
`--contains`, поэтому checkout с `fetch-depth: 0`. Семантику
build-режима несёт ветка, не имя тэга.
2026-05-13 16:40:09 +00:00
Alex Ant 908349ac67 chore(release): publish 2026-05-13 21:36:49 +05:00
Alex Ant 64f8201eb6 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-13 21:36:30 +05:00
coopops 5735e1fc36 ci: build-containers зависит от build-contracts через workflow_run
Раньше при релизе (`chore(release): publish` + git tag) запускались
параллельно `build-contracts.yaml` (push веток) и `build-containers.yaml`
(push тэгов). Поскольку контейнеры собираются ~8 минут, а контракты ~11,
build-containers финишил первым и слал webhook на тестнет за 2-3 минуты
до того, как build-contracts успевал запушить новый `dicoop/contracts:dev`
в DockerHub. Ансибл `setup-contracts.yaml` подтягивал ПРЕДЫДУЩИЙ образ
и через `cleos set contract` перетирал чейн старым wasm.

Инцидент 2026-05-13: walletop-фикс ledger2 (коммит 2e3410b830),
вручную задеплоенный 12-05, был откачен ансиблом ровно по этой
причине — ансибл подтянул контракты сборки 12-05 04:54 (sha de0f6c79,
до моего фикса).

Изменения:
- `build-contracts.yaml` дополнительно триггерится на push любого тэга
  (без path-фильтра): нужен unconditional запуск, чтобы у workflow_run
  всегда был upstream-завершение даже когда коммит ничего не правит
  в `components/contracts/cpp/**`.
- `build-containers.yaml` переключён с `push: tags` на `workflow_run:
  Build contracts container completed`. Внутри: резолв тэга через
  `git describe --tags --exact-match $head_sha` — если на коммите тэга
  нет (обычный push в ветку без релиза), no-op. Если есть — собирает
  контейнеры и шлёт webhook ровно как раньше, но с гарантией что
  `dicoop/contracts:<branch>` уже свежий.

Важно: workflow_run-триггер берёт definition из default-ветки (main),
поэтому новый build-containers.yaml начнёт работать только после мержа
этого коммита в main. До тех пор сохраняется старое поведение dev-ветки
(workflow_run от build-contracts на dev запустит build-containers из
main; если там старая версия — всё ещё через push: tags).
2026-05-13 16:24:12 +00:00
Alex Ant 4a6aa8c447 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-13 20:45:03 +05:00
coopops b2a304e9de test(controller/capital): синхронизировать ассерт под applicable_account_types: []
Build bootstrap container / build (push) Failing after 2m52s
Trigger Contracts Docs Deploy / trigger-coopenomics (push) Failing after 11s
Build contracts container / build (push) Failing after 11m30s
Publish Docs / build-and-publish-docs (push) Failing after 19m16s
В коммите 8847a5c093 production-код registerCapitalInAgreementRegistry
изменил applicable_account_types у blagorost_offer на [] — оферта
тянется через программу CAPITALIZATION, а не как дефолт для individual.
Тест capital-plugin-register.test.ts остался на ассерте
[AccountType.individual] и с тех пор красный.

Выравниваю ассерт под актуальное поведение, удаляю ставший лишним
импорт AccountType.
2026-05-13 15:38:59 +00:00
coopops 6eeb4567b1 fix(controller/capital): BLAGOROST_AGREEMENT_TYPE = 'capital' (on-chain имя)
В `soviet::coagreements[voskhod]` оферта Благорост (program_id=4)
зарегистрирована под `type='capital'` — и на тестнете, и на проде.
Расширение capital после Эпика 1.3 отвечает за on-chain тип оферты,
но в `capital-agreement-ids.ts` значение `'blagorost'` унаследовано
из старого ядерного `AgreementType.CAPITAL`, который, в свою очередь,
был неверно изменён в commit 283af35f3b («looking for access
violation bug»).

Эффект на тестнете: при регистрации любого individual-аккаунта
контроллер шлёт `soviet::sndagreement` с agreement_type='blagorost',
`get_coagreement_or_fail` падает с «Соглашение указанного типа не
найдено», регистрация не завершается.

Возвращаю значение к on-chain имени; обновляю тест и три комментария,
которые декларировали старое значение как канон.
2026-05-13 15:28:31 +00:00
coopops b92abb80a8 fix(ledger2/voskhod): мин.паевые → w.sov.mnused; правка accounts2 (51/08)
migrate_voskhod_facts:
- accounts2[51]: 176 800 → 145 000 (минус 31 800 минП, теперь только деньги)
- accounts2[08]: 543 400 → 575 200 (плюс 31 800 минП — инвестировано в активы)
- accounts2[04/80/86] без изменений; Σ Dr = Σ Cr = 63 073 511 ✓

Новый COOPERATIVE-кошелёк w.sov.mnused для аналитики "использованные
минимальные паевые взносы" (Cr 80 source, перешедшие в 08). Для voskhod
31 800 размещается там вместо w.reg.minshr — обязательство перед пайщиками
на счёте 80 сохранено, но wallet-аналитика отражает что средства уже
ушли в долгосрочные активы.

migrator-048 (Phase 1 L3 → w.reg.minshr): skip voskhod явным if в TS +
жёсткий guard в migrate3 (eosio::check отвергает запись L3(voskhod,
w.reg.minshr) в prod). В testnet-сборке guard выключен — voskhod проходит
стандартный арифметический путь для видимости invariant-фейлов.

Реестр LEDGER2_WALLET_REGISTRY 13 → 14; namespace w.sov.* расширён до
"совет-level фондов" (целевое финансирование + использованные паевые).
TS-зеркало (wallets.generated.ts) регенерировано через pnpm gen:from-cpp;
snapshot-тест cooptypes обновлён.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 15:20:35 +00:00
Alex Ant dcd3c9293a chore(release): publish
Build bootstrap container / build (push) Failing after 2m52s
Trigger Contracts Docs Deploy / trigger-coopenomics (push) Failing after 5s
Build contracts container / build (push) Failing after 2m48s
Publish Docs / build-and-publish-docs (push) Failing after 15m54s
2026-05-13 19:22:44 +05:00
Alex Ant f952be5f83 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-13 19:20:12 +05:00
Alex Ant bd8eb34e43 Merge pull request #365 from coopenomics/onboarding
feat(onboarding): платформенный механизм онбординга кооператива на расширение (issue 549)
2026-05-13 19:19:46 +05:00
coopops 7d9583d862 fix(desktop/session): дашборд и кошелёк — только при status='active'
Промежуточные статусы пайщика (created/joined/payed/registered) больше
не показываются как «уже зарегистрирован». Пайщик с WIF в localStorage,
но без принятия советом, видит публичную главную и кнопки login/register
— как незарегистрированный. Это позволяет ему серфить сайт между шагами
регистрации, не получая преждевременно подпись оферты цифрового кошелька.

Изменения:
- SessionStore.isFullyActive — новый computed, true при user_account.status === 'active'.
- navigation-guard-setup: ветка index выбирает дашборд только для isFullyActive;
  requiresAuth-маршруты при isAuth && !isFullyActive (вне /auth/*) шлёт на index.
- init-wallet: не дёргать wallet.loadUserWallet пока !isFullyActive (account.getAccount
  оставляем — нужен для определения статуса).
- init-app: selectDefaultWorkspace только при isFullyActive (иначе non_authorized).
- Desktop store: ветки в selectDefaultWorkspace / getDefaultPageRoute идут от
  isFullyActive, не isAuth.

Источник истины статуса — миграция V2.2.0 + ParticipantStatusSyncService:
все accepted-пайщики on-chain переводятся в users.status='active' в моно.
Замер на восходе (api.coopenomics.world soviet::participants[scope=voskhod]):
34 accepted + 1 blocked = 35, все «принятые» → ровно active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:40:15 +00:00
coopops 8847a5c093 fix(controller/capital): blagorost_offer — только через программу CAPITALIZATION, не дефолт для individual
При выборе программы GENERATION generateRegistrationDocuments падал с
«Данные соглашения благороста не найдены в Udata»: blagorost_offer с
applicable_account_types: [individual] тянулась как дефолтная оферта,
но generateDocumentParameters под GENERATION зовёт только
generateGeneratorOfferParameters → Factory не находит udata для blagorost
→ Promise.all rejected → фронт получает 0 документов → пайщик ничего
не подписывает → backend бракует "Отсутствуют blagorost_offer, generator_offer".

Фикс: applicable_account_types: [] на blagorost_offer. Оферта подтягивается
исключительно через agreement_ids программы CAPITALIZATION (как и generator_offer
через GENERATION).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:24:04 +00:00
coopops 37b7387830 fix(sdk+desktop/onboarding): vue-tsc types — selector без as any, ComputedRef в интерфейсе
Селектор `extensionOnboardingStateSelector` использовал `as any` на nested-объекте, из-за чего `InputType<...>` терял форму `steps` (резолвился в `unknown`). Убрал `as any` + `as const`, добавил `MakeAllFieldsRequired` валидацию — паттерн как в `commitSelector`.

Composable `useExtensionCooperativeOnboarding` объявлял интерфейс через `ReturnType<typeof computed<T>>`, что резолвится в `WritableComputedRef` (последний overload Vue). Заменил на явные `ComputedRef<T>` / `Ref<T>` — фактическая форма не writable.

Со-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:01:52 +00:00
coopops 968990c3ff feat(sdk+desktop/onboarding): SDK regen + features/CooperativeOnboarding (Эпики A3+A4)
SDK:
- Регенерирован zeus-клиент под новые типы ExtensionOnboardingState и
  CompleteExtensionOnboardingStepInput из платформенного резолвера.
- Добавлен namespace `Queries.Onboarding.GetExtensionOnboardingState`,
  `Mutations.Onboarding.CompleteExtensionOnboardingStep` и селектор
  `extensionOnboardingStateSelector`.

Desktop features/CooperativeOnboarding (FSD):
- `useExtensionCooperativeOnboarding(getExtensionName)` — реактивный
  controller со state/steps/allDone/expiresAt + load/completeStep.
- `<CooperativeOnboardingGate extension="...">` — slot-based wrapper:
  пока !all_done показывает `slot[onboarding]` (по дефолту —
  CooperativeOnboardingSteps), после ратификации всех шагов —
  основной слот.
- `<CooperativeOnboardingSteps>` — список шагов с кнопкой "Создать
  предложение совету"; событие `propose` поднимается parent'у для
  открытия формы — UX-форма остаётся за конкретным расширением.

Использование Стол заказов (и любым новым расширением): обернуть
рабочий экран в `<CooperativeOnboardingGate extension="stol_zakazov">`,
шаги показываются автоматически из платформенного реестра.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:35:45 +00:00
coopops c32d265bb1 feat(controller/onboarding): платформенный механизм онбординга кооператива на расширение (Эпики A1+A2)
Системный паттерн «онбординг кооператива на расширение»:
- OnboardingStepsRegistry — in-memory реестр шагов, регистрация
  декларативно в initialize() расширения.
- ExtensionOnboardingService — generic getState/completeStep, работает
  с любым extension, шаги задаются спецификацией IExtensionOnboardingStepSpec
  с двумя generator'ами: free_decision (создаёт project + опубликовывает +
  регистрирует tracking-rule SOVIET_DECISION) и meet (registerTrackingRule
  MEET_DECISION с externally-provided proposal_hash).
- ExtensionOnboardingEventsService — generic слушатель DecisionTrackedEvent
  для расширений без legacy events-сервиса (chairman/capital — пропускает).
- GraphQL endpoint getExtensionOnboardingState / completeExtensionOnboardingStep
  с ролевой защитой (query open для chairman/member/user, mutation chairman).

A2: chairman и capital декларативно регистрируют свои существующие шаги
через ONBOARDING_STEP_REGISTRATION_PORT — step_key совпадает с
config-полями onboarding_<step_key>_done/hash, поэтому legacy resolver'ы
и платформенный сходятся на единой истине в config'е.

Capital: step_key='blagorost_provision' выбран под существующее поле
onboarding_blagorost_provision_done (legacy enum использует 'blagorost_program').

Юнит-тесты OnboardingStepsRegistry: регистрация/дубликаты/order/unregister.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:27:01 +00:00
coopops a1bee6f630 merge: align worktree base with onboarding branch 2026-05-12 16:18:17 +00:00
coopops 2fd0087e93 feat(desktop/onboarding): features/DocumentSigning — переиспользуемый пакет генерации и подписи (Эпик B1)
Платформенный composable + 2 widget'а для генерации и подписи пачки
документов пайщика. Локальный state (без Pinia) — можно инстанциировать
независимо в любом месте: и в Registrator, и на странице расширения,
онбордящего пайщика по своему flow.

— useDocumentSigning(getOpts) — load() / setAccepted() / signAll() / linkHashes
— <DocumentsChecklist :documents @update:accepted> — чек-лист с ReadAgreementDialog
— <DocumentsSignCanvas @signed> — canvas-подпись, эмитит сигнатуру

Backend генерации (generateRegistrationDocuments mutation +
AgreementRegistry) уже был generic — этот эпик закрывает фронтенд-сторону.

Registrator не переключаем — у него legacy-привязки к
useRegistrationStore + полям store.walletAgreement/etc. в state. B2
переключения Registrator на новые widget'ы — отдельной story, когда
потребуется почистить legacy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:59:25 +00:00
coopops a967311d1e Revert "feat(controller/onboarding): L3-гейт capital (Эпик 3.1) — AgreementSignaturePort"
This reverts commit 7d90d3af2d.
2026-05-12 14:28:32 +00:00
coopops c7db7a9c6f Revert "feat(desktop/onboarding): SDK selector + Vue OfferGate (Эпик 2.2)"
This reverts commit 3db9854a76.
2026-05-12 14:28:32 +00:00
coopops 3db9854a76 feat(desktop/onboarding): SDK selector + Vue OfferGate (Эпик 2.2)
SDK (Zeus regen из обновлённой schema.gql после Эпика 2.1):
- registrationAgreementSelector — все 10 полей RegistrationAgreement
- Queries.Agreements.GetRegistrationAgreements — query с
  filter coopname/account_type/program_key
- Zeus index.ts / const.ts регенерированы (controller + sdk)

Desktop (FSD feature в components/desktop/src/features/OfferGate):
- useOfferGate composable — реактивно проверяет on-chain agreements
  пайщика через Queries.Agreements.Agreements; signed ↔ существует
  запись (coopname, username, type) со status !== DECLINED
- <OfferGate> Vue компонент c props {coopname, username,
  agreementType, offerTitle, signupUrl?} — рендерит slot если оферта
  подписана, иначе q-banner с кнопкой «Перейти к подписанию»
- model/types.ts — типизация props

Симметричен бэкендовому AgreementSignaturePort (Эпик 3.1): один
вердикт без расхождения между UI и сервером.

pnpm typecheck desktop — exit 0. Контракт-тесты controller/sdk —
тоже зелёные (53). План C28-10 раздел 2.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:10:39 +00:00
coopops c970d879bc refactor(controller/onboarding): консолидация TTL онбординга (Эпик 4.2)
- Новый модуль domain/onboarding/constants/onboarding-ttl.ts —
  единый источник правды для ONBOARDING_EXPIRY_DAYS (30),
  ONBOARDING_EXPIRY_MS и helper computeOnboardingExpiresAt(startedAt)
- chairman-extension.module и capital onboarding.service используют
  helper вместо дублированного хардкода `30 * 24 * 60 * 60 * 1000`
- Юнит-тест (4 кейса) фиксирует константы и эквивалентность helper'а
  старому inline-вычислению

Runtime-смок: coopback hot-reload зелёный.

53 unit-тестов зелёные. tsc --noEmit exit 0. План C28-10 раздел 4.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:00:43 +00:00
coopops 7d90d3af2d feat(controller/onboarding): L3-гейт capital (Эпик 3.1) — AgreementSignaturePort
- Новый платформенный порт AgreementSignaturePort + AGREEMENT_SIGNATURE_PORT
  в domain/agreement/ports — расширения проверяют подпись соответствующей
  оферты перед допуском к своим операциям
- AgreementService.hasSigned реализация порта через AGREEMENT_REPOSITORY
  (Postgres backfill on-chain agreements3); подписано ↔ найдена запись
  (coopname, username, type) в любом статусе кроме DECLINED
- @Global() на AgreementModule + useExisting биндинг AgreementService
  на порт; расширения могут инжектить порт без явного импорта модуля
- L3-гейт в InvestsManagementService:
    • createProjectInvest → проверка подписи 'generator' иначе
      ForbiddenException с понятным сообщением для UI;
    • createProgramInvest → проверка подписи 'blagorost' аналогично;
  Используются константы из extensions/capital/constants/capital-agreement-ids.ts
- Юнит-тесты L3-гейта (5 кейсов): отсутствие подписи блокирует обе
  программы, наличие подписи передаёт управление интерактору, подпись
  другого пайщика не открывает доступ (cross-user isolation)

Runtime-смок: coopback hot-reload зелёный (13:53:58), DI порта
зарегистрировано без UnknownDependencies.

49 unit-тестов зелёные. tsc --noEmit exit 0. План C28-10 раздел 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:55:57 +00:00
coopops 117c0c7c06 feat(controller/onboarding): AgreementQueryPort + GraphQL getRegistrationAgreements (Эпик 2.1)
- Новый платформенный порт чтения соглашений и программ
  (AgreementQueryPort, AGREEMENT_QUERY_PORT) в
  domain/registration/ports — резолверы и расширения инжектят его
  вместо AgreementConfigurationService напрямую, чтобы граница чтения
  оставалась стабильной при будущей перестройке реализации
- useExisting AgreementConfigurationService → AGREEMENT_QUERY_PORT
  биндинг в registration-domain.module
- RegistrationAgreementDTO для GraphQL — спецификация оферты
  (не подписанное Agreement), сливает платформенные + extension-
  зарегистрированные в едином формате
- Новый GraphQL Query getRegistrationAgreements(coopname, account_type,
  program_key?): [RegistrationAgreement!]! — в RegistrationResolver
- RegistrationService.getRegistrationAgreements делегирует порту
- Контрактный тест AGREEMENT_QUERY_PORT (5 кейсов): structural
  implementation check, 4 платформенные оферты для individual,
  пустые программы при пустом реестре, getAgreementById для
  существующей и несуществующей оферты
- Auto-regenerated components/controller/schema.gql

Runtime-смок: GraphQL introspect RegistrationAgreement даёт 10 полей,
живой query getRegistrationAgreements(voskhod, individual) возвращает
4 платформенные оферты с корректным order/applicable_account_types.

44 unit-тестов зелёные. tsc --noEmit exit 0. План C28-10 раздел 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:47:55 +00:00
coopops 71b20e382d feat(controller/onboarding): платформенный паттерн трёхуровневого онбординга (Эпики 1.1-1.3)
Эпик 1.1 — платформенная инфраструктура реестра:
- AgreementRegistrationSpec / ProgramRegistrationSpec DTO
- AgreementRegistrationPort интерфейс + AGREEMENT_REGISTRATION_PORT токен
- AgreementRegistryService с in-memory state, идемпотентностью по
  (id, extension_name), ConflictException на конфликт владельца,
  tear-down через подписку на EXTENSION_APP_TERMINATE_EVENT
- ONBOARDING_COMPLETED_EVENT + подписка ExtensionLifecycleDomainService
  на восстановление расширения после завершения L1-онбординга

Эпик 1.2 — Capital и Chairman регистрируются через port:
- CapitalPlugin.initialize() → registerCapitalInAgreementRegistry
  при завершённом L1 (5 _done флагов) регистрирует 2 оферты
  (generator/blagorost) и 2 программы (generation/capitalization)
- CapitalOnboardingEventsService.handleDecisionTracked после
  blockchain newresolved эмитит ONBOARDING_COMPLETED_EVENT при
  переходе последнего L1 _done false→true (idempotency через
  wasAlreadyDone guard)
- ChairmanOnboardingEventsService — аналогичный эмит при
  завершении 7 L1 шагов председателя

Эпик 1.3 — чистка ядра controller от capital-специфики:
- AgreementId/AgreementType enum'ы сокращены до 4 платформенных
  (signature/wallet/user/privacy); BLAGOROST_OFFER/GENERATOR_OFFER
  и CAPITAL/GENERATOR удалены
- registration-programs.config.ts удалён (voskhod-hardcode уехал
  в registry capital); registration-agreements.config.ts сокращён
- CooperativeConfigService.getExcludedFromBaseAgreements удалён
- IAgreementConfigItem/IRegistrationProgram типы id/agreement_type/
  key/agreement_ids ослаблены до string — ядро не знает значений
  расширений
- AgreementConfigurationService переписан: inject AgreementRegistryService,
  слияние платформенных оферт с extension-зарегистрированными;
  программы читаются только из registry
- system.service.getRegistrationConfig: requires_selection
  вычисляется как programs.length > 1
- participant.interactor.mapAgreementIdToDocumentType: case'ы
  capital удалены, identity-fallback по Object.values(DocumentType)
- Capital: новый файл constants/capital-agreement-ids.ts —
  локальный source-of-truth для строковых значений оферт/типов/
  программ; используется в register-capital-in-agreement-registry

Тесты: 39 unit-тестов в 5 suites (agreement-registry, capital
plugin-register, capital onboarding-events, chairman onboarding-events,
existing access-policy-union) — все зелёные.

Остаётся как техдолг (не входит в 1.x): DocumentType.BLAGOROST_OFFER/
GENERATOR_OFFER (физические колонки таблицы candidates), ProgramKey
enum в ядре (используется в blockchain payload и switch case
registration-documents.service).

План C28-10, ветка onboarding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:13:00 +00:00
Alex Ant 3561888781 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-12 11:14:51 +05:00
coopops 2e3410b830 fix(ledger2/walletop): убрать O(N) sweep инварианта Σ L3 == L2 из hot path
Полная сверка Σ L3 == L2 через линейный проход secondary-индекса bywallet
выполнялась на каждом walletop для обеих сторон USER_SHARED-кошельков
(стоимость O(N_users) на кошелёк × 2 стороны). На coop'ах с сотнями
пайщиков это даёт квадратичный рост CPU billing на пользовательских
транзакциях, а на тестнете дополнительно блокирует tx из-за исторических
расхождений после миграций 048/049.

Инвариант сохраняется по построению: walletop применяет одно и то же
amount к L2 и L3, а sender-guard (walletop.cpp:46-47) запрещает обход.
Полную сверку выносим на бэкенд («стол бухгалтера») вне hot path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 06:03:00 +00:00
Alex Ant de0f6c7956 chore(release): publish 2026-05-12 09:54:19 +05:00
Alex Ant 5f41b74023 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-12 09:53:57 +05:00
coopops c5eeef124c feat(capital/wallet/V2.3.1): подписи Благороста через regcontrib + backfill orphan'ов
- capital::regcontrib больше не делает dual-write openprogwall для
  program_id=3/4 — остаётся только inline wallet::signagree (документы
  попадают в источник правды wallet::users.programs[]).
- wallet::migrate3 принимает coopname@active помимо wallet@active —
  чтобы контроллер кооператива мог сам бэкфиллить из своей БД.
- V2.3.1 миграция: достаёт реальные подписанные документы Благороста и
  Генератора из blockchain_actions локального controller-PG (audit trail
  capital::regcontrib actions), пушит wallet::migrate3 с реальным
  doc_hash и signed_at для каждого orphan'а в program_id=3/4.
- migrationManager: прокинул VaultDomainService в Migration interface
  (для blockchain.initialize(coopname, wif) перед transact).

Контракты задеплоены на testnet:
  capital  22a882d25395b29692e85bdd10860469341f7783d51538ea96c9685748cd31ab
  wallet   53a12723d77a2a7786044908c44bb6f0117cfc4225009b1f4d532ab5f59fa92a
2026-05-12 04:22:27 +00:00
Alex Ant e270b95996 chore(release): publish 2026-05-11 23:26:00 +05:00
Alex Ant 8d8b01f148 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-11 23:25:41 +05:00
coopops c5922f672f feat(controller/V2.3.0): backfill agreements из soviet::agreements3 в Postgres
На дев/тест-кооперативах controller стартовал со снапшота, старые подписи
(status='') до момента запуска sync не пришли через delta-stream — фронт
требует подписать user/signature/privacy заново, хотя они подписаны на цепи.

Миграция читает agreements3 целиком по scope=COOPNAME через RPC и UPSERT'ит
каждую запись по id. Программные (program_id > 0) пропускает — они идут на
чтение через wallet::users.programs[]. domain-status не трогаем.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:25:32 +00:00
Alex Ant bb1d8558e7 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-11 23:11:03 +05:00
coopops e5151758fb fix(migrator/049): blocked может быть undefined (binary_extension) — подменять нулём
soviet::progwallets имеет blocked и membership_contribution как
binary_extension. На старых записях (например voskhod/enzqwnqsdqar/1)
eosjs возвращает undefined — pushAsset падает с
"Expected string containing asset". Защищаемся ?? zeroAssetLike(pw.available).
Для membership ?? уже был.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:10:42 +00:00
Alex Ant a575da57f8 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-11 23:08:28 +05:00
coopops 9352c77cd7 feat(migrate3): подписывать миграционные actions самим контрактом, не coopname
У нас нет приватных ключей от кооперативов, которых мы мигрируем.
Поэтому ledger2::migrate3 и wallet::migrate3 теперь принимают auth от
get_self() (т.е. ledger2@active / wallet@active), и они же платят за RAM.
Mig-скрипты 047/048/049 пушат actions с актором = имя контракта.

Также правки в ledger2::migrate (Эпик 1) с прошлой сессии: default-case
для неизвестных legacy account IDs (862-867) — `break;` вместо
eosio_assert (ignore non-canonical), плюс IS_TESTNET-блок с clamp'ом
грязных legacy-данных.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:07:17 +00:00
Alex Ant add7646720 chore(release): publish 2026-05-11 15:38:44 +05:00
Alex Ant 0d8006d981 chore(release): publish 2026-05-11 12:02:00 +05:00
Alex Ant f9d253117c Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-11 12:01:42 +05:00
coopops c6b144661f [@ant] fix(ledger2/migrate): test-build толерантен к грязным legacy-остаткам
На IS_TESTNET=1 сборке вместо abort'а clamp'им:
- entry > cash → entry = cash, share_money = 0;
- РИД-часть на legacy 80 → игнорируется (лишний остаток теряется);
- Σ p.minimum_amount > share → clamp Σmin = share, share_remain = 0.

В prod (без IS_TESTNET) три инварианта остаются строгими: миграция падает
с прежним явным сообщением, оператор разбирается ad-hoc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 07:01:12 +00:00
Alex Ant a55005a212 chore(release): publish 2026-05-11 11:28:06 +05:00
Alex Ant 5ae6f4963d Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-11 11:27:49 +05:00
coopops f64e028709 [@ant] fix(ledger2/migrate): игнорировать legacy acc id вне канона (51/80/861)
Группа 862..867 (RESERVE/INDIVISIBLE/ECONOMIC/MUTUAL/DEVELOPMENT/DELEGATE_FEES)
в ledger2 отдельными кошельками не выделена. Падать на ненулевом 867
(найдено на testnet) — блокирует миграцию; вместо этого пропускаем.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:27:31 +00:00
coopops 33f44dc823 [@ant] fix(migrator/047): мигрировать program-соглашения с пустым статусом
Фильтр `status === 'confirmed'` пропускал всё: после Эпика 2 для program_id > 0
sndagreement пишет ""_n, а confirmagree больше не вызывается (воркфлоу ушёл
в wallet::signagree). Заменено на `status !== 'declined'`; wallet::migrate3
идемпотентен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:46:45 +00:00
Alex Ant dd88047d76 chore(release): publish 2026-05-11 09:57:46 +05:00
Alex Ant b9d06e4e68 Merge pull request #357 from coopenomics/reports
[989] платформа ФНС/ФСС отчетов и еще 1000 фиксов
2026-05-11 09:56:58 +05:00
222 changed files with 14088 additions and 2745 deletions
+6
View File
@@ -27,6 +27,12 @@ PG_HOST_PORT=5532
COOPBACK_HOST_PORT=2998
DESKTOP_HOST_PORT=2999
OPENSEARCH_HOST_PORT=9200
MINIO_HOST_PORT=9000
MINIO_CONSOLE_HOST_PORT=9001
# MinIO root credentials (только для локального dev; на prod подменяются плейбуком).
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
# URL для скриптов и кода, запускаемого с хоста (boot, networks.sh,
# preactivate.sh, health.ts). Должны соответствовать host-портам выше.
-166
View File
@@ -1,166 +0,0 @@
name: Build Docker Images
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Debug info
run: |
echo "Текущая ветка:"
git branch --show-current
echo "Последние коммиты:"
git log -n 3 --oneline
echo "Проверяем файлы в директории components/desktop/src-ssr:"
ls -la components/desktop/src-ssr/ || echo "Директория не найдена!"
echo "Проверяем файлы в middlewares:"
ls -la components/desktop/src-ssr/middlewares/ || echo "Директория middlewares не найдена!"
- name: Set docker tags
run: |
if [[ $GITHUB_REF == refs/tags/* ]]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "DOCKER_TAG=$TAG_NAME" >> $GITHUB_ENV
# Проверяем, является ли тег продакшн-тегом (не содержит alpha, beta, rc и т.д.)
if [[ ! $TAG_NAME =~ -(alpha|beta|rc|test) ]]; then
echo "IS_PRODUCTION_TAG=true" >> $GITHUB_ENV
echo "Это продакшн тег, будем добавлять latest"
else
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
echo "Это не продакшн тег, latest не добавляем"
fi
else
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
fi
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Сначала собираем базовый образ с runtime
- name: Build base image
run: |
docker build --target runtime -t dicoop/mono-base:${{ env.DOCKER_TAG }} .
docker push dicoop/mono-base:${{ env.DOCKER_TAG }}
# Если это продакшн тег, добавляем latest
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/mono-base:${{ env.DOCKER_TAG }} dicoop/mono-base:latest
docker push dicoop/mono-base:latest
fi
# Создаем сервисные образы на основе базового
- name: Build desktop image
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.desktop
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/desktop\", \"run\", \"start\"]" >> Dockerfile.desktop
docker build -t dicoop/desktop:${{ env.DOCKER_TAG }} -f Dockerfile.desktop .
docker push dicoop/desktop:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/desktop:${{ env.DOCKER_TAG }} dicoop/desktop:latest
docker push dicoop/desktop:latest
fi
- name: Build controller image
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.coopback
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/controller\", \"run\", \"start\"]" >> Dockerfile.coopback
docker build -t dicoop/coopback:${{ env.DOCKER_TAG }} -f Dockerfile.coopback .
docker push dicoop/coopback:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/coopback:${{ env.DOCKER_TAG }} dicoop/coopback:latest
docker push dicoop/coopback:latest
fi
# TEMP: парсер используется существующим деплоем как dicoop/cooparser,
# пока не мигрировали на dicoop/parser из отдельного coopenomics/parser repo.
# Оставляем сборку и пуш образа до завершения миграции потребителей.
- name: Build parser image
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.cooparser
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/parser\", \"run\", \"start\"]" >> Dockerfile.cooparser
docker build -t dicoop/cooparser:${{ env.DOCKER_TAG }} -f Dockerfile.cooparser .
docker push dicoop/cooparser:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/cooparser:${{ env.DOCKER_TAG }} dicoop/cooparser:latest
docker push dicoop/cooparser:latest
fi
- name: Build notificator image
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notificator
echo "CMD [\"pnpm\", \"-F\", \"coop-notificator\", \"run\", \"start\"]" >> Dockerfile.notificator
docker build -t dicoop/notificator:${{ env.DOCKER_TAG }} -f Dockerfile.notificator .
docker push dicoop/notificator:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/notificator:${{ env.DOCKER_TAG }} dicoop/notificator:latest
docker push dicoop/notificator:latest
fi
- name: Build notifications image
run: |
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notifications
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/notifications\", \"run\", \"sync\"]" >> Dockerfile.notifications
docker build -t dicoop/notifications:${{ env.DOCKER_TAG }} -f Dockerfile.notifications .
docker push dicoop/notifications:${{ env.DOCKER_TAG }}
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
docker tag dicoop/notifications:${{ env.DOCKER_TAG }} dicoop/notifications:latest
docker push dicoop/notifications:latest
fi
# Отправка хука для деплоя
- name: Trigger deployment webhook
if: ${{ success() }}
run: |
if [[ $GITHUB_REF == refs/tags/*alpha* ]]; then
# Хук для тестнета (alpha теги)
curl -X POST ${{ vars.TESTNET_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '${{ env.DOCKER_TAG }}'
elif [[ $GITHUB_REF == refs/tags/* ]]; then
# Хук для продакшена (остальные теги)
curl -X POST ${{ vars.PRODUCTION_WEBHOOK_URL }} \
-H 'Content-Type: application/json' \
-d '${{ env.DOCKER_TAG }}'
fi
# Уведомление в Telegram об успехе
- name: Telegram notify success
if: ${{ success() }}
run: |
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
ADDITIONAL_INFO=" (с тегом latest)"
else
ADDITIONAL_INFO=""
fi
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="✅ [GITHUB MONO] Успешная сборка контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]$ADDITIONAL_INFO"
# Уведомление в Telegram об ошибке
- name: Telegram notify failure
if: ${{ failure() }}
run: |
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
-d text="❌ [GITHUB MONO] Ошибка при сборке контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]"
+33 -3
View File
@@ -1,18 +1,48 @@
# .github/workflows/trigger-coopenomics.yml
name: Trigger Contracts Docs Deploy
# Триггерит rebuild docs в coopenomics/coopenomics только по продакшн-
# тэгам на main. Раньше дёргался на каждый push в dev/testnet/main/capital,
# теперь — только формальный релиз (без -alpha/-beta/-rc/-test).
on:
push:
branches: [dev, testnet, main, capital] # или когда нужно триггерить
tags: ['v*']
jobs:
gate:
runs-on: ubuntu-latest
outputs:
should_trigger: ${{ steps.check.outputs.should_trigger }}
steps:
- name: Checkout (для git branch --contains)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gate — main + non-alpha
id: check
run: |
TAG="${{ github.ref_name }}"
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
echo "Тэг $TAG — pre-release, docs deploy не дёргаем"
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
echo "Тэг $TAG не на main — docs deploy не дёргаем"
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Тэг $TAG — продакшн релиз на main, дёргаем coopenomics"
echo "should_trigger=true" >> "$GITHUB_OUTPUT"
trigger-coopenomics:
needs: gate
if: needs.gate.outputs.should_trigger == 'true'
runs-on: ubuntu-latest
steps:
- name: Trigger Coopenomics deployment
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.COOPENOMICS_PAT }}
repository: coopenomics/coopenomics # укажи правильный owner/repo
repository: coopenomics/coopenomics
event-type: deploy_from_mono
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
+23 -20
View File
@@ -7,12 +7,18 @@ name: Build contracts container
# ke-bootstrap'ом (отдельный артефакт), который при старте ноды деплоит
# контракты через `cleos set contract`.
#
# Триггер — push в одну из веток-окружений (dev / testnet / main).
# Маппинг:
# Триггер — push в одну из веток-окружений (dev / testnet / main) с
# изменением .cpp/CMakeLists/build-скриптов. Маппинг:
# dev → IS_TESTNET=ON, tag=dev
# testnet → IS_TESTNET=ON, tag=testnet
# main → IS_TESTNET=OFF, tag=main + latest
#
# ВАЖНО: тэги `v*` тут НЕ обрабатываются — они уезжают в `release.yaml`,
# который делает релиз атомарно (контракты + контейнеры + webhook в
# одном job'е). См. release.yaml для контекста — там в комментарии
# описана причина (гонка build-contracts и build-containers по push'у
# тэга, инцидент 2026-05-13 с ledger2 walletop sweep'ом).
#
# Сам compile идёт через CDT-образ `dicoop/blockchain_v5.1.1:dev`
# (тот же, что использует `components/contracts/build.sh|build-all.sh`).
# Артефакты копируются в slim alpine-образ.
@@ -22,15 +28,12 @@ name: Build contracts container
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID — уведомления
on:
push:
branches: [dev, testnet, main]
paths:
- 'components/contracts/cpp/**'
- 'components/contracts/CMakeLists.txt'
- 'components/contracts/build.sh'
- 'components/contracts/build-all.sh'
- 'components/contracts/docker/**'
- '.github/workflows/build-contracts.yaml'
# Только ручной запуск. Тэги (релизы) обрабатывает release.yaml,
# который собирает контракты атомарно вместе с контейнерами и
# webhook'ом — отдельная сборка по push'у в ветку только мешает
# (две параллельные сборки на каждый release-bump). Здесь оставляем
# workflow_dispatch для ручной пересборки `dicoop/contracts:<branch>`
# (например, чтобы прокатить wasm на dev-ноду без релизного тэга).
workflow_dispatch:
jobs:
@@ -44,19 +47,19 @@ jobs:
run: |
case "${{ github.ref_name }}" in
main)
echo "BUILD_MODE=prod" >> $GITHUB_ENV
echo "DOCKER_TAG=main" >> $GITHUB_ENV
echo "EXTRA_TAG=latest" >> $GITHUB_ENV
echo "BUILD_MODE=prod" >> "$GITHUB_ENV"
echo "DOCKER_TAG=main" >> "$GITHUB_ENV"
echo "EXTRA_TAG=latest" >> "$GITHUB_ENV"
;;
testnet)
echo "BUILD_MODE=test" >> $GITHUB_ENV
echo "DOCKER_TAG=testnet" >> $GITHUB_ENV
echo "EXTRA_TAG=" >> $GITHUB_ENV
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
echo "DOCKER_TAG=testnet" >> "$GITHUB_ENV"
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
;;
dev)
echo "BUILD_MODE=test" >> $GITHUB_ENV
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
echo "EXTRA_TAG=" >> $GITHUB_ENV
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
echo "DOCKER_TAG=dev" >> "$GITHUB_ENV"
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
;;
*)
echo "Unsupported branch ${{ github.ref_name }}" >&2
+32 -6
View File
@@ -1,16 +1,42 @@
name: Publish Docs
# Публикация только по продакшн-тэгам, лежащим на main (без -alpha/-beta/-rc/-test).
# Раньше триггерилось на каждый push в main/testnet/dev/reports/marketplace2 →
# docs пересобирались по 5+ раз в день впустую. Теперь — только релиз.
on:
push:
branches:
- main
- testnet
- dev
- reports
- marketplace2
tags: ['v*']
jobs:
gate:
runs-on: ubuntu-latest
outputs:
should_publish: ${{ steps.check.outputs.should_publish }}
steps:
- name: Checkout (для git branch --contains)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gate — main + non-alpha
id: check
run: |
TAG="${{ github.ref_name }}"
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
echo "Тэг $TAG — pre-release, docs не публикуем"
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
echo "Тэг $TAG не на main — docs не публикуем"
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Тэг $TAG — продакшн релиз на main, публикуем docs"
echo "should_publish=true" >> "$GITHUB_OUTPUT"
build-and-publish-docs:
needs: gate
if: needs.gate.outputs.should_publish == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
+276
View File
@@ -0,0 +1,276 @@
name: Release
# Атомарный релизный workflow. Триггерится исключительно push'ем тэга
# `v*`. В одном job'е по порядку:
# 1) собирает контракты под mode (prod/test) и пушит `dicoop/contracts:<branch>`,
# 2) собирает базовый образ + сервисные контейнеры и пушит `dicoop/<svc>:<tag>`,
# 3) шлёт webhook деплоя (testnet/production по типу тэга).
#
# Зачем атомарно: раньше `build-contracts` и `build-containers`
# крутились параллельно по push'у тэга, и webhook от build-containers
# (~8 мин) обгонял окончание build-contracts (~11 мин) → ансибл на
# тестнете подхватывал ПРЕДЫДУЩИЙ образ контрактов и перетирал чейн
# старым wasm. Попытка связать их через workflow_run упёрлась в
# default-branch caveat и потерю head_sha в YAML-выражениях. Теперь
# нет двух workflow'ов — нет гонки, нет fallback'ов.
#
# Триггер по веткам (dev/testnet/main без тэга) тут НЕ обрабатывается —
# для CI-обновления `dicoop/contracts:<branch>` без релиза остаётся
# `build-contracts.yaml`.
#
# Резолв ветки. Тэг семантики окружения не несёт (например
# `v2026.5.13-alpha-2` может лежать на testnet или dev — определяет
# именно ветка, на которую сделан merge перед тэгом). Поэтому ищем
# первое совпадение SHA с remote-ветками main/testnet/dev.
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve branch, build mode and tags
run: |
TAG_NAME="${{ github.ref_name }}"
SHA="${{ github.sha }}"
BRANCH=""
for candidate in main testnet dev; do
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
BRANCH="$candidate"
break
fi
done
if [ -z "$BRANCH" ]; then
echo "::error::Тэг $TAG_NAME ($SHA) не лежит ни на одной из dev/testnet/main"
exit 1
fi
case "$BRANCH" in
main)
BUILD_MODE=prod
CONTRACTS_TAG=main
CONTRACTS_EXTRA=latest
;;
testnet)
BUILD_MODE=test
CONTRACTS_TAG=testnet
CONTRACTS_EXTRA=
;;
dev)
BUILD_MODE=test
CONTRACTS_TAG=dev
CONTRACTS_EXTRA=
;;
esac
# Прод-тэги (без -alpha/-beta/-rc/-test) уезжают на боевой
# webhook + получают тэг :latest у сервисных образов.
if [[ "$TAG_NAME" =~ -(alpha|beta|rc|test) ]]; then
IS_PROD=false
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
else
IS_PROD=true
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
fi
{
echo "TAG_NAME=$TAG_NAME"
echo "SHA=$SHA"
echo "BRANCH=$BRANCH"
echo "BUILD_MODE=$BUILD_MODE"
echo "CONTRACTS_TAG=$CONTRACTS_TAG"
echo "CONTRACTS_EXTRA=$CONTRACTS_EXTRA"
echo "IS_PROD=$IS_PROD"
echo "WEBHOOK_URL=$WEBHOOK_URL"
} >> "$GITHUB_ENV"
echo "Release $TAG_NAME → branch=$BRANCH, build_mode=$BUILD_MODE, contracts:$CONTRACTS_TAG, prod=$IS_PROD"
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# === Этап 1: контракты ===
- name: Pull CDT toolchain image
run: docker pull dicoop/blockchain_v5.1.1:dev
- name: Compile contracts
working-directory: components/contracts
run: |
./build-all.sh "$BUILD_MODE"
echo "--- build/contracts ---"
ls -la build/contracts/
- name: Stage contracts docker context
working-directory: components/contracts
run: |
ROOT="docker/.context"
rm -rf "$ROOT"
mkdir -p "$ROOT/contracts"
# Имена контрактов = аргументы add_contract_build(...) только в
# незакомменченных строках (CMake-комментарий начинается с `#`).
NAMES=$(grep -E '^[[:space:]]*add_contract_build\(' CMakeLists.txt \
| sed -E 's/^[[:space:]]*add_contract_build\(([^)]*)\).*/\1/')
echo "Contracts to package:"
echo "$NAMES"
MANIFEST="$ROOT/contracts/manifest.json"
pack_one() {
local NAME="$1" WASM="$2" ABI="$3"
if [ ! -f "$WASM" ] || [ ! -f "$ABI" ]; then
echo "::warning::Skipping $NAME: artifacts missing ($WASM / $ABI)" >&2
return 1
fi
mkdir -p "$ROOT/contracts/$NAME"
cp "$WASM" "$ROOT/contracts/$NAME/$NAME.wasm"
cp "$ABI" "$ROOT/contracts/$NAME/$NAME.abi"
local SHA_WASM=$(sha256sum "$WASM" | awk '{print $1}')
local SHA_ABI=$(sha256sum "$ABI" | awk '{print $1}')
printf ' {"name":"%s","sha256_wasm":"%s","sha256_abi":"%s"}' \
"$NAME" "$SHA_WASM" "$SHA_ABI"
}
{
printf '{\n'
printf ' "build_mode": "%s",\n' "$BUILD_MODE"
printf ' "git_sha": "%s",\n' "$SHA"
printf ' "tag": "%s",\n' "$TAG_NAME"
printf ' "branch": "%s",\n' "$BRANCH"
printf ' "built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf ' "contracts": [\n'
FIRST=1
emit() {
entry=$(pack_one "$@") || return 0
[ $FIRST -eq 0 ] && printf ',\n'
FIRST=0
printf '%s' "$entry"
}
for c in $NAMES; do
if [ "$c" = "system" ]; then
# У system многоконтрактная сборка: разносим под-контракты
# eosio.* как полноценные элементы каталога, сам "system"
# как имя пропускаем.
for sub in build/contracts/system/contracts/eosio.*/; do
[ -d "$sub" ] || continue
sub_name=$(basename "$sub")
emit "$sub_name" "$sub/$sub_name.wasm" "$sub/$sub_name.abi"
done
else
emit "$c" "build/contracts/$c/$c.wasm" "build/contracts/$c/$c.abi"
fi
done
printf '\n ]\n'
printf '}\n'
} > "$MANIFEST"
cp docker/Dockerfile "$ROOT/Dockerfile"
cp docker/entrypoint.sh "$ROOT/entrypoint.sh"
chmod +x "$ROOT/entrypoint.sh"
echo "--- manifest.json ---"
cat "$MANIFEST"
- name: Build and push contracts image
working-directory: components/contracts
run: |
IMAGE="dicoop/contracts"
SHORT_SHA="${SHA::7}"
docker build \
--label "org.opencontainers.image.revision=$SHA" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "build.mode=$BUILD_MODE" \
-t "$IMAGE:$CONTRACTS_TAG" \
./docker/.context
docker push "$IMAGE:$CONTRACTS_TAG"
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
docker push "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
if [ -n "$CONTRACTS_EXTRA" ]; then
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
docker push "$IMAGE:$CONTRACTS_EXTRA"
fi
- name: Verify pushed contracts image
run: |
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" list
echo "---"
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" sha256
# === Этап 2: контейнеры приложений ===
- name: Build and push base image
run: |
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
docker push "dicoop/mono-base:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
docker push dicoop/mono-base:latest
fi
- name: Build and push service images
run: |
build_service() {
local SVC="$1" PKG="$2" CMD="$3"
local DOCKERFILE="Dockerfile.$SVC"
{
echo "FROM dicoop/mono-base:$TAG_NAME"
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
} > "$DOCKERFILE"
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
docker push "dicoop/$SVC:$TAG_NAME"
if [ "$IS_PROD" = "true" ]; then
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
docker push "dicoop/$SVC:latest"
fi
}
build_service desktop '@coopenomics/desktop' start
build_service coopback '@coopenomics/controller' start
# TEMP: cooparser — пока не мигрировали потребителей на dicoop/parser
# из отдельного coopenomics/parser repo.
build_service cooparser '@coopenomics/parser' start
build_service notificator 'coop-notificator' start
build_service notifications '@coopenomics/notifications' sync
# === Этап 3: webhook деплоя ===
- name: Trigger deployment webhook
run: |
curl -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "$TAG_NAME"
- name: Telegram notify success
if: success()
run: |
EXTRA=""
if [ "$IS_PROD" = "true" ]; then
EXTRA=" (с тегом latest)"
fi
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
-d "text=✅ [GITHUB MONO] Релиз $TAG_NAME ($BRANCH, contracts:$CONTRACTS_TAG, sha=${SHA::7})$EXTRA"
- name: Telegram notify failure
if: failure()
run: |
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
-d "text=❌ [GITHUB MONO] Ошибка релиза $TAG_NAME ($BRANCH, sha=${SHA::7})"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.5.10-4",
"version": "2026.5.14-2",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.5.10-4",
"version": "2026.5.14-2",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.5.10-4",
"version": "2026.5.14-2",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
@@ -99,31 +99,6 @@ void capital::regcontrib(eosio::name coopname, eosio::name username, checksum256
Capital::Contributors::create_contributor(coopname, username, contributor_hash, is_external_contract, contract_for_send, rate_per_hour, hours_per_day);
}
// Открываем кошельки для пайщика если необходимо
if (!has_program_wallet(coopname, username, _source_program)) {
Action::send<openprogwall_interface>(
_soviet,
Names::External::OPEN_PROGRAM_WALLET,
_capital,
coopname,
username,
_source_program,
uint64_t(0)
);
}
if (!has_program_wallet(coopname, username, _capital_program)) {
Action::send<openprogwall_interface>(
_soviet,
Names::External::OPEN_PROGRAM_WALLET,
_capital,
coopname,
username,
_capital_program,
uint64_t(0)
);
}
std::string memo = "";
if (is_external_contract) {
@@ -160,32 +160,19 @@ void ledger2::walletop(eosio::name coopname,
" для кошелька " + wallet_id.to_string());
};
// ── Post-mutation assert Σ L3 == L2 (Story 3.2; NFR2) ─────────────────
// Последняя линия защиты инварианта L3 L2. Считается только для
// затронутых USER_SHARED-кошельков. Стоимость O(N_users) на кошелёк;
// падение здесь = откат всей tx (включая бух-проводки в debit/credit).
auto assert_sum_l3_equals_l2 = [&](eosio::name wallet_id) {
if (wallet_id.value == 0) return;
if (ledger2_get_wallet_kind(wallet_id) != WalletKind::USER_SHARED) return;
auto wallet_it = wallets.find(wallet_id.value);
int64_t l2_avail = wallet_it == wallets.end() ? 0 : wallet_it->available.amount;
int64_t l2_block = wallet_it == wallets.end() ? 0 : wallet_it->blocked.amount;
int64_t sum_avail = 0, sum_block = 0;
auto idx = user_wallets.get_index<"bywallet"_n>();
for (auto it = idx.lower_bound(wallet_id.value);
it != idx.end() && it->wallet_name == wallet_id;
++it) {
sum_avail += it->available.amount;
sum_block += it->blocked.amount;
}
eosio::check(sum_avail == l2_avail && sum_block == l2_block,
std::string{"walletop: инвариант Σ L3 == L2 нарушен на "} +
wallet_id.to_string() +
" (L2: " + std::to_string(l2_avail) + "/" + std::to_string(l2_block) +
", Σ L3: " + std::to_string(sum_avail) + "/" + std::to_string(sum_block) + ")");
};
// ── Post-mutation Σ L3 == L2 (Story 3.2; NFR2) ────────────────────────
// ВРЕМЕННО СНЯТО (2026-05-12): runtime-сверка Σ L3 == L2 через полный
// sweep secondary-индекса bywallet — O(N_users) на кошелёк на каждой
// walletop-операции; на боевых coop'ах с сотнями пайщиков это
// непропорциональная нагрузка на CPU billing, и сейчас на тестнете она
// блокирует пользовательские tx из-за исторических расхождений после
// миграций 048/049.
//
// walletop по построению применяет одно и то же `amount` к L2 и L3 (см.
// ниже case'ы ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN), а sender-guard на
// строке 46-47 запрещает обход. Поэтому инвариант сохраняется по
// конструкции; полную сверку выполняет бэкенд («стол бухгалтера»),
// вне транзакционного hot path.
// Pre-flight cross-contract check (до мутаций — fail-fast).
assert_program_signed(wallet_from);
@@ -317,11 +304,6 @@ void ledger2::walletop(eosio::name coopname,
}
}
// Post-mutation: инвариант Σ L3 == L2 на каждом затронутом кошельке.
// Проверяется только для USER_SHARED-кошельков с непустым username
// (для COOPERATIVE и L2-only-mode инварианта L3⊆L2 нет).
if (username.value != 0) {
assert_sum_l3_equals_l2(wallet_from);
assert_sum_l3_equals_l2(wallet_to);
}
// Post-mutation Σ L3 == L2: см. блок выше (строки 163-175). Снято
// временно — сверка делается на бэкенде вне hot path.
}
@@ -95,10 +95,10 @@ inline LegacyBalances read_legacy_balances(eosio::name coopname) {
case Ledger::accounts::SHARE_FUND: r.share = total; break; // 80
case Ledger::accounts::ENTRANCE_FEES: r.entry = total; break; // 861
default:
// Все прочие legacy-id в проде нулевые. Ненулевое — сигнал.
eosio::check(false,
std::string{"migrate: неожиданный ненулевой legacy acc id="} +
std::to_string(acc_it->id));
// Прочие legacy-id (862..867 — RESERVE/INDIVISIBLE/ECONOMIC/MUTUAL/DEVELOPMENT/DELEGATE_FEES)
// в ledger2 как отдельные кошельки не выделены. Намеренно пропускаем —
// не падаем, чтобы не блокировать миграцию кооператива.
break;
}
}
return r;
@@ -380,30 +380,37 @@ inline void emplace_preimp_if_present(eosio::name self_name,
* напрямую по согласованной с бухгалтером картине.
*
* ┌────────────────────────────────────── accounts2 ──────────────────────────────────────┐
* │ 51 (BANK_ACCOUNT, А) = 176 800 — банк (хардкод 145 000) + 31 800 минП
* │ 51 (BANK_ACCOUNT, А) = 145 000 — 145 000 деньги
* │ 04 (INTANGIBLE_ASSETS, А) = 62 353 311 — имущ. Благорост 56 903 311 + preimp 5М4 │
* │ 08 (NON_CURRENT_INVESTMENTS) = 543 400 — балансировка (свод НМА → 80)
* │ 08 (NON_CURRENT_INVESTMENTS) = 575 200 — свод (балансировка) + 31 800 минП
* │ 80 (SHARE_FUND, П) = 62 946 011 — 419 900 ЦК + 57 044 311 Благорост │
* │ + 31 800 минП + 5 450 000 preimp Cr-side │
* │ 86 (TARGET_RECEIPTS, П) = 127 500 — хоз.расходы 115К + legacy 861 12 500 │
* │ │
* │ Σ Dr = 176 800 + 62 353 311 + 543 400 = 63 073 511 │
* │ Σ Dr = 145 000 + 62 353 311 + 575 200 = 63 073 511 │
* │ Σ Cr = 62 946 011 + 127 500 = 63 073 511 ✓ │
* └─────────────────────────────────────────────────────────────────────────────────────────┘
*
* ┌────────────────────────────── wallets2 (L2-агрегаты) ─────────────────────────────┐
* │ w.reg.minshr available = 31 800 (34 пайщика; minimum_amount; L3 → migrator-048)
* │ w.sov.mnused available = 31 800 (мин.паевые, ушедшие в 08; COOPERATIVE)
* │ w.wal.share available = 419 900 (5 пайщиков ЦК-остаток; L3 → migrator-049) │
* │ w.cap.blago blocked = 57 044 311 (12 пайщиков pid=4; L3 → migrator-049) │
* │ w.cap.preimp available = 5 450 000 (5 пайщиков; L3 — ниже, прямой emplace) │
* │ w.sov.expns available = 127 500 (хоз.расходы из числа целевого; COOPERATIVE) │
* └────────────────────────────────────────────────────────────────────────────────────┘
*
* L3 (userwallets) для USER_SHARED-кошельков заводит migrator (Phase 1 = 048
* для w.reg.minshr; Phase 2 = 049 для w.wal.share / w.wal.member / w.cap.blago).
* Здесь же — только 5 пред-импорт-преимп-записей, выпавших из progwallets
* после ручных subbal'ов (см. ~/cleos.md, 5 пайщиков с РИД-взносами по
* договорам УХД, не успевшим попасть в электронный учёт до миграции).
* w.reg.minshr на voskhod НЕ создаётся: мин.паевые (31 800) уже инвестированы в
* НМА (08), поэтому L2-аналитика лежит на w.sov.mnused (COOPERATIVE, без L3).
* Бухгалтерия (Cr 80 += 31 800; Dr 08 += 31 800) при этом сохранена — обязательство
* перед пайщиками на 80 счёте не теряется. Из-за этого migrator-048 (L3 для
* w.reg.minshr) на voskhod не запускается; жёсткий guard в migrate3 отвергает
* запись L3(voskhod, w.reg.minshr).
*
* L3 (userwallets) для USER_SHARED-кошельков заводит migrator (Phase 2 = 049
* для w.wal.share / w.wal.member / w.cap.blago). Здесь же — только 5 пред-импорт-
* преимп-записей, выпавших из progwallets после ручных subbal'ов (см. ~/cleos.md,
* 5 пайщиков с РИД-взносами по договорам УХД, не успевшим попасть в электронный
* учёт до миграции).
*/
inline void migrate_voskhod_facts(eosio::name self_name, const cooperative2& coop) {
const eosio::name coopname = coop.username;
@@ -412,18 +419,21 @@ inline void migrate_voskhod_facts(eosio::name self_name, const cooperative2& coo
// ───────── accounts2 ─────────
emplace_account_balance(self_name, coopname, ledger2_accounts::BANK_ACCOUNT,
eosio::asset( 1'768'000'000LL, sym)); // 176 800.0000 RUB
eosio::asset( 1'450'000'000LL, sym)); // 145 000.0000 RUB
emplace_account_balance(self_name, coopname, ledger2_accounts::INTANGIBLE_ASSETS,
eosio::asset( 623'533'110'000LL, sym)); // 62 353 311.0000 RUB
emplace_account_balance(self_name, coopname, ledger2_accounts::NON_CURRENT_INVESTMENTS,
eosio::asset( 5'434'000'000LL, sym)); // 543 400.0000 RUB
eosio::asset( 5'752'000'000LL, sym)); // 575 200.0000 RUB
emplace_account_balance(self_name, coopname, ledger2_accounts::SHARE_FUND,
eosio::asset( 629'460'110'000LL, sym)); // 62 946 011.0000 RUB
emplace_account_balance(self_name, coopname, ledger2_accounts::TARGET_RECEIPTS,
eosio::asset( 1'275'000'000LL, sym)); // 127 500.0000 RUB
// ───────── wallets2 (L2) ─────────
emplace_wallet_balance(self_name, coopname, ledger2_wallets::MIN_SHARE_FUND,
// Мин.паевые на voskhod сразу размещены на w.sov.mnused (COOPERATIVE):
// бухгалтерски они уже потрачены через 08 (Dr 08 += 31 800), но обязательство
// Cr 80 += 31 800 сохранено. L3 для w.reg.minshr НЕ создаётся — см. guard в migrate3.
emplace_wallet_balance(self_name, coopname, ledger2_wallets::MIN_SHARE_USED,
eosio::asset( 318'000'000LL, sym), // 31 800.0000 RUB available
zero);
emplace_wallet_balance(self_name, coopname, ledger2_wallets::SHARE_FUND_PAY,
@@ -483,6 +493,28 @@ inline void migrate_one_coop(eosio::name self_name, const cooperative2& coop) {
// ----- A. Бухгалтерский перенос legacy::accounts через 3 TRANSIT_* -----
if (b.cash.amount > 0 || b.share.amount > 0 || b.entry.amount > 0) {
#ifdef IS_TESTNET
// На тестнете терпим грязные данные: clamp'им вместо abort.
// Тестовые остатки (entry > cash, РИД-часть на 80, Σmin > share) — игнорятся.
const int64_t entry_amt = b.entry.amount < b.cash.amount ? b.entry.amount : b.cash.amount;
const int64_t share_money_amt = b.cash.amount - entry_amt;
const eosio::asset entry_eff(entry_amt, _root_govern_symbol);
const eosio::asset share_money(share_money_amt, _root_govern_symbol);
eosio::asset min_total = compute_min_total_by_type(coopname, coop);
if (min_total.amount > share_money.amount) {
min_total = share_money;
}
const eosio::asset share_remain(
share_money.amount - min_total.amount,
_root_govern_symbol
);
send_transit(self_name, coopname, operations::migration::MIN_SHARE, min_total);
send_transit(self_name, coopname, operations::migration::SHARE, share_remain);
send_transit(self_name, coopname, operations::migration::ENTRY, entry_eff);
#else
// Деньги на паевой фонд: всё на 51 сверх вступительных.
const eosio::asset share_money(
b.cash.amount >= b.entry.amount ? b.cash.amount - b.entry.amount : 0,
@@ -521,6 +553,7 @@ inline void migrate_one_coop(eosio::name self_name, const cooperative2& coop) {
send_transit(self_name, coopname, operations::migration::MIN_SHARE, min_total);
send_transit(self_name, coopname, operations::migration::SHARE, share_remain);
send_transit(self_name, coopname, operations::migration::ENTRY, b.entry);
#endif
}
// ----- B. Программные кошельки — прямой emplace, без Dr/Cr -----
@@ -564,8 +597,11 @@ void ledger2::migrate(uint64_t from_coop_index, uint64_t limit) {
if (idx < from_coop_index) continue;
if (done >= limit) { reached_end = false; break; }
// Мигрируем только кооперативы (не отделения и не физ. записи).
if (it->is_cooperative) {
// Мигрируем только активные кооперативы (не отделения, не физ. записи,
// не приостановленные/архивные). apply() всё равно отказывает не-active
// через get_cooperative_or_fail — здесь явно пропускаем, чтобы курсор шёл.
const bool is_active = it->status.has_value() && it->status.value() == "active"_n;
if (it->is_cooperative && is_active) {
migrate_one_coop(get_self(), *it);
}
++done;
@@ -16,7 +16,9 @@
*
* Auto-delete: если переданы (available=0, blocked=0) — запись удаляется.
*
* Auth: `coopname@active`. Payer: `coopname` (NFR11 — RAM на коопе).
* Auth: `ledger2@active` (через `get_self()`), payer: `ledger2`. Так миграция
* работает для ЛЮБОГО кооператива без ключей от coopname — у нас по контракту
* есть только своя подпись. RAM, расход на которую — это сам контракт.
*
* @ingroup public_ledger2_actions
*/
@@ -26,7 +28,7 @@ void ledger2::migrate3(eosio::name coopname,
eosio::name username,
eosio::asset available,
eosio::asset blocked) {
require_auth(coopname);
require_auth(get_self());
eosio::check(coopname.value != 0, "migrate3: coopname пустой");
get_cooperative_or_fail(coopname);
@@ -34,6 +36,15 @@ void ledger2::migrate3(eosio::name coopname,
eosio::check(wallet_name.value != 0, "migrate3: wallet_name пустой");
eosio::check(ledger2_is_known_wallet(wallet_name),
std::string{"migrate3: неизвестный wallet_name "} + wallet_name.to_string());
#ifndef IS_TESTNET
// voskhod: мин.паевые перенесены на w.sov.mnused (COOPERATIVE) при миграции —
// см. migrate_voskhod_facts. L3 для w.reg.minshr на voskhod не создаётся,
// иначе сломается инвариант Σ L3 == L2 (L2 пуст, а 048 написал бы 34 L3-записи).
eosio::check(!(coopname == "voskhod"_n && wallet_name == ledger2_wallets::MIN_SHARE_FUND),
"migrate3: voskhod не использует w.reg.minshr — мин.паевые на w.sov.mnused "
"(см. migrate_voskhod_facts); migrator-048 должен пропускать voskhod");
#endif
eosio::check(ledger2_get_wallet_kind(wallet_name) == WalletKind::USER_SHARED,
std::string{"migrate3: wallet_name "} + wallet_name.to_string() +
" не USER_SHARED — L3-запись не имеет смысла");
@@ -57,7 +68,7 @@ void ledger2::migrate3(eosio::name coopname,
if (it == idx.end()) {
if (is_zero) return; // нечего создавать; безопасный no-op для повторов
const uint64_t new_id = user_wallets.available_primary_key();
user_wallets.emplace(coopname, [&](auto& uw) {
user_wallets.emplace(get_self(), [&](auto& uw) {
uw.id = new_id;
uw.wallet_name = wallet_name;
uw.username = username;
@@ -69,7 +80,7 @@ void ledger2::migrate3(eosio::name coopname,
idx.erase(it);
} else {
auto pri = user_wallets.find(it->id);
user_wallets.modify(pri, coopname, [&](auto& uw) {
user_wallets.modify(pri, get_self(), [&](auto& uw) {
uw.available = available;
uw.blocked = blocked;
});
@@ -22,7 +22,7 @@
* Группы:
* w.wal.* — Паевой фонд (журнал Cr 80) и возвраты пайщикам
* w.reg.* — Регистрация (минимальный паевой, вступительные)
* w.sov.* — Целевое финансирование (членские, делегатские)
* w.sov.* — Совет-level фонды (целевое финансирование, использованные паевые)
* w.cap.* — Программы паевого фонда (Благорост, Генератор) и займы
* w.mkt.* — Маркетплейс (выплаты поставщикам)
*
@@ -52,10 +52,11 @@ struct ledger2_wallets {
static constexpr eosio::name MIN_SHARE_FUND = "w.reg.minshr"_n; ///< Минимальный паевой взнос пайщика (USER_SHARED, без сверки соглашений)
static constexpr eosio::name ENTRANCE_FEES = "w.reg.entry"_n; ///< Вступительные взносы (Cr 86, COOPERATIVE)
// soviet — членские (инфраструктура) + делегатские + хоз.расходы
// soviet — членские (инфраструктура) + делегатские + хоз.расходы + использованные паевые
static constexpr eosio::name INFRA_FEES = "w.sov.infra"_n; ///< Членские взносы за инфраструктуру кооп. платформы (COOPERATIVE)
static constexpr eosio::name DELEGATE_FEES = "w.sov.delgte"_n; ///< Делегатские членские взносы (цель CONVERT_TO_AXN, COOPERATIVE)
static constexpr eosio::name SOV_EXPENSES = "w.sov.expns"_n; ///< Хозяйственные расходы из числа целевого финансирования (COOPERATIVE)
static constexpr eosio::name MIN_SHARE_USED = "w.sov.mnused"_n; ///< Использованные минимальные паевые взносы (Cr 80 source, перешедшие в 08; COOPERATIVE)
// capital — единые программные кошельки + займы + пред-импорт
static constexpr eosio::name LOAN_ISSUED = "w.cap.loan"_n; ///< Выданные пайщикам беспроцентные займы (COOPERATIVE; Dr 58 / Cr 51)
@@ -93,7 +94,7 @@ struct Ledger2WalletMeta {
WalletKind kind;
};
inline constexpr std::array<Ledger2WalletMeta, 13> LEDGER2_WALLET_REGISTRY = {{
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (6) — L3-разрез по пайщику
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
@@ -102,12 +103,13 @@ inline constexpr std::array<Ledger2WalletMeta, 13> LEDGER2_WALLET_REGISTRY = {{
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
// COOPERATIVE (7) — единый кооперативный баланс, без L3
// COOPERATIVE (8) — единый кооперативный баланс, без L3
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам", WalletKind::COOPERATIVE },
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
{ ledger2_wallets::DELEGATE_FEES, "Делегатские членские взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::SOV_EXPENSES, "Хозяйственные расходы из числа целевого финансирования", WalletKind::COOPERATIVE },
{ ledger2_wallets::MIN_SHARE_USED, "Использованные минимальные паевые взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
}};
@@ -12,7 +12,7 @@
* (soviet::agreements3) и заполняет state как есть. Полные проверки делает
* нормальный flow `signagree`.
*
* @param coopname Кооператив (auth: coopname@active, payer)
* @param coopname Кооператив (ключей от него у нас нет — поэтому НЕ авторизатор)
* @param username Пайщик
* @param program_id Идентификатор программы
* @param doc_hash Хэш документа из `agreements3.document.hash`
@@ -22,7 +22,11 @@
* @ingroup public_actions
* @ingroup public_wallet_actions
*
* @note Авторизация требуется от аккаунта: @p coopname (active)
* @note Auth: `wallet@active` (через `get_self()`) ИЛИ `coopname@active`.
* Первый путь — для централизованного migrator (раскатка из CI). Второй —
* для self-service backfill из контроллера кооператива (BlockchainService
* имеет WIF только coopname; для wallet@active отдельного ключа нет).
* Payer: тот, кто подал auth (wallet или сам кооператив).
*/
[[eosio::action]] void wallet::migrate3(
eosio::name coopname,
@@ -33,7 +37,8 @@
uint64_t draft_id,
eosio::time_point signed_at
) {
require_auth(coopname);
const eosio::name payer = has_auth(get_self()) ? get_self() : coopname;
if (payer != get_self()) require_auth(coopname);
Wallet::users_index users(_wallet, coopname.value);
auto user_it = users.find(username.value);
@@ -47,12 +52,12 @@
};
if (user_it == users.end()) {
users.emplace(coopname, [&](auto &row) {
users.emplace(payer, [&](auto &row) {
row.username = username;
row.programs = { pa };
});
} else {
users.modify(user_it, coopname, [&](auto &row) {
users.modify(user_it, payer, [&](auto &row) {
auto it = std::find_if(
row.programs.begin(), row.programs.end(),
[&](const Wallet::program_agreement &p) { return p.program_id == program_id; });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.5.10-4",
"version": "2026.5.14-2",
"private": true,
"type": "module",
"scripts": {
+13
View File
@@ -84,3 +84,16 @@ OPENAI_API_KEY=
OPENAI_BASE_URL=
WHISPER_MODEL=whisper-1
WHISPER_LANGUAGE=ru
# FILE STORAGE (MinIO в dev compose; на prod подменяется плейбуком).
# Endpoint S3-совместимого бэкенда. В dev compose service name = minio.
# Для запуска контроллера на хосте (вне docker) — http://127.0.0.1:9000.
MINIO_ENDPOINT=http://minio:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
# Имя физического бакета. По умолчанию `coop-${COOPNAME}`, можно перекрыть.
MINIO_BUCKET=
# HMAC-секрет для подписи URL отдачи. Если пусто — берётся SERVER_SECRET.
FILE_STORAGE_SIGNING_SECRET=
# База публичного URL контроллера для read-URL. Если пусто — берётся BACKEND_URL.
FILE_STORAGE_PUBLIC_BASE_URL=
+3
View File
@@ -8,6 +8,9 @@ module.exports = {
NODE_ENV: 'test',
},
restoreMocks: true,
// Интеграционные тесты против внешних сервисов (MinIO и т.п.) — не часть штатного `jest`-прогона.
// Запускаются явно через `npm run test:integration:file-storage`.
testPathIgnorePatterns: ['/node_modules/', '\\.integration\\.spec\\.ts$'],
coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.ts', 'tests'],
coverageReporters: ['text', 'lcov', 'clover', 'html'],
moduleNameMapper: {
@@ -0,0 +1,208 @@
import type { DataSource } from 'typeorm';
type MigrationLogger = {
info: (message: string) => void;
error: (message: string) => void;
warn: (message: string) => void;
};
interface IChainAgreement {
id: number;
coopname: string;
username: string;
type: string;
program_id: number;
draft_id: number;
version: number;
document: {
version?: string;
hash?: string;
doc_hash?: string;
meta_hash?: string;
meta?: string;
signatures?: any[];
};
status: string;
updated_at: string;
}
/**
* Backfill `agreements` таблицы из блокчейна (`soviet::agreements3`).
*
* На дев/тест-кооперативах controller был поднят со снапшота, и старые
* подписи (status='') до момента запуска sync не попали в Postgres через
* delta-stream. Фронту это видится так: пайщик подписывал когда-то, но
* `RequireAgreements` снова просит подписать `user`/`signature`/`privacy`.
*
* Миграция читает `agreements3` целиком по `scope = COOPNAME` через RPC и
* UPSERT'ит каждую запись по полю `id`. Не трогает domain-status (`status`),
* чтобы не сломать состояния, выставленные нормальным flow — UPSERT с
* `DO UPDATE` пишет только поля, известные из блокчейна.
*
* Источник истины — блокчейн (`BLOCKCHAIN_RPC` env). RPC недоступен / пусто —
* падаем явно (не молча).
*/
export default {
name: 'Backfill agreements из soviet::agreements3 в Postgres',
async up({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
try {
const rpcUrl = process.env.BLOCKCHAIN_RPC;
const coopname = process.env.COOPNAME ?? 'voskhod';
if (!rpcUrl) {
logger.error('BLOCKCHAIN_RPC env не задан — миграция не применяется');
return false;
}
logger.info(`Чтение soviet::agreements3[scope=${coopname}] из ${rpcUrl}`);
const rows = await fetchAllAgreements(rpcUrl, coopname, logger);
logger.info(`Получено ${rows.length} записей agreements3.`);
if (rows.length === 0) {
logger.info('Нет agreements3 — нечего вгружать.');
return true;
}
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const row of rows) {
// Программные соглашения отдаются на чтение из wallet::users.programs[],
// не из `agreements`. Чтобы не получить дубль во фронте — заливаем
// в БД только непрограммные.
if (Number(row.program_id) > 0) { skipped += 1; continue; }
const document = convertChainDocumentToDomain(row.document);
const blockchainStatus = String(row.status ?? '');
const updatedAt = new Date(row.updated_at);
const result: Array<{ inserted: boolean }> = await dataSource.query(
`
INSERT INTO agreements (
block_num, present, status, _created_at, _updated_at,
id, coopname, username, type, program_id, draft_id, version,
document, blockchain_status, updated_at
) VALUES (
0, true, 'registered', now(), now(),
$1, $2, $3, $4, $5, $6, $7,
$8::json, $9, $10
)
ON CONFLICT (id) DO UPDATE SET
coopname = EXCLUDED.coopname,
username = EXCLUDED.username,
type = EXCLUDED.type,
program_id = EXCLUDED.program_id,
draft_id = EXCLUDED.draft_id,
version = EXCLUDED.version,
document = EXCLUDED.document,
blockchain_status = EXCLUDED.blockchain_status,
updated_at = EXCLUDED.updated_at,
present = true,
_updated_at = now()
RETURNING (xmax = 0) AS inserted
`,
[
row.id,
row.coopname,
row.username,
row.type,
row.program_id,
row.draft_id,
row.version,
JSON.stringify(document),
blockchainStatus,
updatedAt,
],
);
if (result[0]?.inserted) inserted += 1; else updated += 1;
}
logger.info(`Backfill завершён: insert ${inserted}, update ${updated}, skip programmatic ${skipped}, всего ${rows.length}.`);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
logger.error(`Ошибка backfill agreements: ${message}${stack ? `\n${stack}` : ''}`);
return false;
}
},
async down({ logger }: { logger: MigrationLogger }): Promise<boolean> {
logger.warn(
'Откат не выполняется: backfill идемпотентен по id, повторный up даст тот же результат.',
);
return true;
},
};
function convertChainDocumentToDomain(chainDoc: IChainAgreement['document']) {
return {
version: chainDoc?.version,
hash: chainDoc?.hash,
doc_hash: chainDoc?.doc_hash,
meta_hash: chainDoc?.meta_hash,
meta:
typeof chainDoc?.meta === 'string'
? chainDoc.meta === ''
? {}
: safeJsonParse(chainDoc.meta)
: (chainDoc?.meta ?? {}),
signatures: chainDoc?.signatures ?? [],
};
}
function safeJsonParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return {};
}
}
async function fetchAllAgreements(
rpcUrl: string,
coopname: string,
logger: MigrationLogger,
): Promise<IChainAgreement[]> {
const items: IChainAgreement[] = [];
let lower_bound: string | undefined = '';
for (let page = 0; page < 10000; page++) {
const body = {
json: true,
code: 'soviet',
scope: coopname,
table: 'agreements3',
limit: 1000,
lower_bound,
};
const res = await fetch(`${rpcUrl.replace(/\/$/, '')}/v1/chain/get_table_rows`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`RPC ${res.status} ${res.statusText}: ${await res.text()}`);
}
const data = (await res.json()) as {
rows: IChainAgreement[];
more: boolean;
next_key?: string;
};
items.push(...data.rows);
if (!data.more) return items;
if (!data.next_key) {
logger.warn('RPC ответил more=true без next_key — прерываем пагинацию');
return items;
}
lower_bound = data.next_key;
}
throw new Error('Превышен лимит страниц при чтении soviet::agreements3');
}
@@ -0,0 +1,303 @@
import type { DataSource } from 'typeorm';
import type { BlockchainService } from '~/infrastructure/blockchain/blockchain.service';
import type { VaultDomainService } from '~/domain/vault/services/vault-domain.service';
import { WalletContract } from 'cooptypes';
import config from '~/config/config';
type MigrationLogger = {
info: (message: string) => void;
error: (message: string) => void;
warn: (message: string) => void;
};
interface IDocSignature {
signed_at?: string;
signer?: string;
}
interface IBlagorostDoc {
hash?: string;
doc_hash?: string;
version?: string;
signatures?: IDocSignature[];
}
interface IRegcontribData {
coopname: string;
username: string;
blagorost_agreement?: IBlagorostDoc | null;
generator_agreement?: IBlagorostDoc | null;
}
interface IProgramAgreementRow {
program_id: number | string;
}
interface IWalletUserRow {
username: string;
programs: IProgramAgreementRow[];
}
interface ISovietProgramRow {
id: number | string;
draft_id: number | string;
}
interface IDraftRow {
id: number | string;
version: number | string;
}
const BLAGOROST_PROGRAM_ID = 4;
const GENERATOR_PROGRAM_ID = 3;
const ZERO_HASH = '0000000000000000000000000000000000000000000000000000000000000000';
/**
* Backfill `wallet::users.programs[]` для исторических контрибуторов: их
* `regcontrib` приехал ДО того, как capital научился делать inline
* `wallet::signagree`. Подписанные документы лежат в `blockchain_actions.data`
* как audit trail, но в state контракта `wallet::users` их не оказалось —
* фронт ругается «У пайщика нет кошелька в программе Благорост».
*
* Поток:
* 1. SELECT всех capital::regcontrib из локального controller-Postgres.
* 2. Группировка по (coopname, username) — последний `regcontrib` выигрывает
* (после importcontrib обычно ещё один regcontrib).
* 3. Для каждой пары через RPC проверяем `wallet::users.programs[]`.
* 4. Если program_id=4 (Благорост) ещё нет и `data.blagorost_agreement`
* валидный — пушим `wallet::migrate3` от `coopname@active` с реальным
* `doc_hash` и `signed_at = signatures[0].signed_at`. То же для
* program_id=3 (Генератор).
*
* Auth: `coopname@active` — после расширения `wallet::migrate3` (контракт
* принимает либо `wallet@active`, либо `coopname@active`).
*
* Идемпотентно: повторный запуск увидит уже подписанных и пропустит их.
*
* Активна до 2027-05-01: к этому моменту все исторические orphan'ы должны
* быть закрыты, а новые регистрации идут через корректный `regcontrib`.
*/
export default {
name: 'Backfill wallet::users.programs[] из capital::regcontrib (Благорост/Генератор)',
validUntil: new Date('2027-05-01T00:00:00Z'),
async up({
dataSource,
blockchain,
vault,
logger,
}: {
dataSource: DataSource;
blockchain: BlockchainService;
vault: VaultDomainService;
logger: MigrationLogger;
}): Promise<boolean> {
try {
const rpcUrl = config.blockchain.url;
if (!rpcUrl) {
logger.error('BLOCKCHAIN_RPC не задан — миграция не применяется');
return false;
}
const rawRows = (await dataSource.query(
`SELECT data
FROM blockchain_actions
WHERE account = 'capital' AND name = 'regcontrib'
ORDER BY block_num ASC`,
)) as Array<{ data: IRegcontribData }>;
logger.info(`regcontrib actions найдено: ${rawRows.length}`);
if (rawRows.length === 0) {
logger.info('Нет regcontrib в локальной БД — нечего бэкфиллить.');
return true;
}
const latestByPair = new Map<string, IRegcontribData>();
for (const r of rawRows) {
latestByPair.set(`${r.data.coopname}/${r.data.username}`, r.data);
}
logger.info(`Уникальных (coopname, username): ${latestByPair.size}`);
const coopnames = Array.from(new Set([...latestByPair.values()].map((d) => d.coopname)));
const draftVersions = await fetchDraftVersions(rpcUrl, logger);
let pushed = 0;
let skipped = 0;
let missing = 0;
let failed = 0;
for (const coopname of coopnames) {
const wif = await vault.getWif(coopname);
if (!wif) {
logger.warn(`WIF для ${coopname} не найден в vault — пропускаю кооператив`);
continue;
}
blockchain.initialize(coopname, wif);
const usersPrograms = await fetchWalletUserPrograms(rpcUrl, coopname, logger);
const programs = await fetchSovietPrograms(rpcUrl, coopname, logger);
for (const [pairKey, regData] of latestByPair) {
if (regData.coopname !== coopname) continue;
const targets: Array<{ programId: number; doc: IBlagorostDoc }> = [];
if (isValidAgreement(regData.blagorost_agreement)) {
targets.push({ programId: BLAGOROST_PROGRAM_ID, doc: regData.blagorost_agreement! });
}
if (isValidAgreement(regData.generator_agreement)) {
targets.push({ programId: GENERATOR_PROGRAM_ID, doc: regData.generator_agreement! });
}
if (targets.length === 0) continue;
const already = usersPrograms.get(regData.username) ?? new Set<number>();
for (const { programId, doc } of targets) {
if (already.has(programId)) {
skipped += 1;
continue;
}
const program = programs.get(programId);
if (!program) {
missing += 1;
logger.warn(
`${pairKey}: программа program_id=${programId} не найдена в soviet::programs — skip`,
);
continue;
}
const draftId = Number(program.draft_id);
const version = draftId > 0 ? draftVersions.get(draftId) ?? 0 : 0;
const docHash = (doc.hash ?? doc.doc_hash) as string;
const signedAt =
doc.signatures?.[0]?.signed_at ?? new Date().toISOString().replace(/\.\d+Z$/, '');
const actionData: WalletContract.Actions.Migrate3.IMigrate3 = {
coopname: regData.coopname,
username: regData.username,
program_id: programId,
doc_hash: docHash,
version,
draft_id: draftId,
signed_at: signedAt,
};
try {
await blockchain.transact({
account: WalletContract.contractName.production,
name: WalletContract.Actions.Migrate3.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data: actionData,
});
pushed += 1;
logger.info(
`migrate3 OK ${pairKey} program_id=${programId} draft_id=${draftId} ` +
`ver=${version} hash=${docHash.slice(0, 12)}`,
);
} catch (err) {
failed += 1;
const msg = err instanceof Error ? err.message : String(err);
logger.error(`migrate3 FAIL ${pairKey} program_id=${programId}: ${msg}`);
}
}
}
}
logger.info(
`Backfill завершён: push ${pushed}, уже подписаны ${skipped}, программа не найдена ${missing}, ` +
`ошибок ${failed}.`,
);
return failed === 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
logger.error(`Ошибка backfill blagorost signagree: ${message}${stack ? `\n${stack}` : ''}`);
return false;
}
},
async down({ logger }: { logger: MigrationLogger }): Promise<boolean> {
logger.warn(
'Откат не выполняется: backfill идемпотентен и работает только для orphan-программ. ' +
'Удаление записей wallet::users делается вручную через wallet::revokeagree.',
);
return true;
},
};
function isValidAgreement(doc: IBlagorostDoc | null | undefined): doc is IBlagorostDoc {
if (!doc) return false;
const hash = doc.hash ?? doc.doc_hash;
return typeof hash === 'string' && hash.length === 64 && hash !== ZERO_HASH;
}
async function rpcGetTableRows<T>(
rpcUrl: string,
payload: Record<string, unknown>,
logger: MigrationLogger,
): Promise<T[]> {
const items: T[] = [];
let lower_bound: string | undefined = '';
for (let page = 0; page < 10_000; page++) {
const res = await fetch(`${rpcUrl.replace(/\/$/, '')}/v1/chain/get_table_rows`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ json: true, limit: 1000, lower_bound, ...payload }),
});
if (!res.ok) throw new Error(`RPC ${res.status} ${await res.text()}`);
const data = (await res.json()) as { rows: T[]; more: boolean; next_key?: string };
items.push(...data.rows);
if (!data.more) return items;
if (!data.next_key) {
logger.warn('RPC ответил more=true без next_key — прерываем пагинацию');
return items;
}
lower_bound = data.next_key;
}
throw new Error('Превышен лимит страниц get_table_rows');
}
async function fetchWalletUserPrograms(
rpcUrl: string,
coopname: string,
logger: MigrationLogger,
): Promise<Map<string, Set<number>>> {
const rows = await rpcGetTableRows<IWalletUserRow>(
rpcUrl,
{ code: 'wallet', scope: coopname, table: 'users' },
logger,
);
const out = new Map<string, Set<number>>();
for (const r of rows) {
out.set(r.username, new Set(r.programs.map((p) => Number(p.program_id))));
}
return out;
}
async function fetchSovietPrograms(
rpcUrl: string,
coopname: string,
logger: MigrationLogger,
): Promise<Map<number, ISovietProgramRow>> {
const rows = await rpcGetTableRows<ISovietProgramRow>(
rpcUrl,
{ code: 'soviet', scope: coopname, table: 'programs' },
logger,
);
const out = new Map<number, ISovietProgramRow>();
for (const r of rows) out.set(Number(r.id), r);
return out;
}
async function fetchDraftVersions(
rpcUrl: string,
logger: MigrationLogger,
): Promise<Map<number, number>> {
const rows = await rpcGetTableRows<IDraftRow>(
rpcUrl,
{ code: 'draft', scope: 'draft', table: 'drafts' },
logger,
);
const out = new Map<number, number>();
for (const r of rows) out.set(Number(r.id), Number(r.version));
return out;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2026.5.10-4",
"version": "2026.5.14-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.5.10-4",
"version": "2026.5.14-2",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2026.5.10-4",
"version": "2026.5.14-2",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
@@ -22,6 +22,7 @@
"dev": "nodemon --watch src --watch migrations --ext ts,js,env --exec 'ts-node -r tsconfig-paths/register' src/index.ts",
"test": "jest -i --testPathPattern=tests/unit",
"test:integration": "echo 'Integration tests run via @coopenomics/boot' && exit 0",
"test:integration:file-storage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testRegex 'tests/file-storage/.*\\.integration\\.spec\\.ts$' --testPathIgnorePatterns='/node_modules/' --testTimeout=60000 -i",
"test:watch": "jest -i --watchAll",
"coverage": "jest -i --coverage",
"coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls",
@@ -71,6 +72,7 @@
],
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
"@aws-sdk/client-s3": "^3.1035.0",
"@coopenomics/factory": "workspace:*",
"@coopenomics/inter": "workspace:*",
"@coopenomics/notifications": "workspace:*",
@@ -193,6 +195,7 @@
"@graphql-codegen/typescript": "^4.1.1",
"@graphql-codegen/typescript-apollo-client-helpers": "^3.0.0",
"@graphql-codegen/typescript-operations": "^4.3.1",
"@nestjs/testing": "^11.1.19",
"@redocly/cli": "^1.18.0",
"@types/express": "^4.17.21",
"@types/node": "^20.11.26",
File diff suppressed because it is too large Load Diff
+13
View File
@@ -16,6 +16,7 @@ import { EventsInfrastructureModule } from './infrastructure/events/events.modul
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
import { FileStorageInfrastructureModule } from './infrastructure/file-storage';
// Domain modules
import { AccountDomainModule } from './domain/account/account-domain.module';
@@ -41,6 +42,7 @@ import { ProcessRegistryDomainModule } from './domain/process-registry/process-r
import { ParserDomainModule } from './domain/parser/parser-domain.module';
import { SettingsInfrastructureModule } from './infrastructure/settings/settings-infrastructure.module';
import { RegistrationDomainModule } from './domain/registration/registration-domain.module';
import { OnboardingDomainModule } from './domain/onboarding/onboarding-domain.module';
import { TokenDomainModule } from './domain/token/token-domain.module';
import { MutationLogDomainModule } from './domain/mutation-log/mutation-log-domain.module';
@@ -74,6 +76,7 @@ import { UserModule } from './application/user/user.module';
import { TokenApplicationModule } from './application/token/token-application.module';
import { SettingsApplicationModule } from './application/settings/settings.module';
import { RegistrationModule } from './application/registration/registration.module';
import { OnboardingApplicationModule } from './application/onboarding/onboarding-application.module';
import { SearchModule } from './application/search/search.module';
import { MutationLoggingInterceptor } from './application/common/interceptors/mutation-logging.interceptor';
@@ -100,9 +103,18 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
EventsInfrastructureModule,
FreeDecisionInfrastructureModule,
DecisionTrackingInfrastructureModule,
FileStorageInfrastructureModule.forRoot({
endpoint: config.file_storage.endpoint,
accessKey: config.file_storage.access_key,
secretKey: config.file_storage.secret_key,
bucket: config.file_storage.bucket,
signingSecret: config.file_storage.signing_secret,
publicBaseUrl: config.file_storage.public_base_url,
}),
// Domain modules
AuthDomainModule,
RegistrationDomainModule,
OnboardingDomainModule,
AgendaDomainModule,
AccountDomainModule,
AccountInfrastructureModule,
@@ -156,6 +168,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
TokenApplicationModule,
SettingsApplicationModule,
RegistrationModule,
OnboardingApplicationModule,
SearchModule,
],
providers: [
@@ -121,7 +121,7 @@ export class AgreementService {
* Программные DTO синтезируются: `id`/`document` отсутствуют (полный документ
* лежит в action data, а не в state); `status=CONFIRMED` (запись в `users`
* существует только для подписанных соглашений). Поле `type` берётся из
* `soviet::coagreements[program_id].type` (тот же 'wallet'/'blagorost' и т.п.,
* `soviet::coagreements[program_id].type` (тот же 'wallet'/'capital' и т.п.,
* который виджет `RequireAgreements` использует для матчинга подписи); если
* программа без коагримента — fallback `'programmatic'`.
*/
@@ -0,0 +1,64 @@
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
import { IsString, IsBoolean } from 'class-validator';
import { Cooperative } from 'cooptypes';
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
// утилита для выборки повторяющихся параметров из базовых интерфейсов
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id' | 'appendix_hash'>;
// интерфейс параметров для генерации
type action = Cooperative.Registry.GenerationConvertStatement.Action;
@InputType(`BaseGenerationConvertStatementMetaDocumentInput`)
class BaseGenerationConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
@Field({ description: 'Хэш проекта' })
@IsString()
project_hash!: string;
@Field({ description: 'Сумма для перевода в Цифровой Кошелёк' })
@IsString()
main_wallet_amount!: string;
@Field({ description: 'Сумма для перевода в программу «Благорост»' })
@IsString()
blagorost_wallet_amount!: string;
@Field({ description: 'Признак перевода в Цифровой Кошелёк' })
@IsBoolean()
to_wallet!: boolean;
@Field({ description: 'Признак перевода в программу «Благорост»' })
@IsBoolean()
to_blagorost!: boolean;
}
@InputType(`GenerationConvertStatementGenerateDocumentInput`)
export class GenerationConvertStatementGenerateDocumentInputDTO extends IntersectionType(
BaseGenerationConvertStatementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
) {
registry_id!: number;
constructor() {
super();
}
}
@InputType(`GenerationConvertStatementSignedMetaDocumentInput`)
export class GenerationConvertStatementSignedMetaDocumentInputDTO
extends IntersectionType(BaseGenerationConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
implements action {
@Field({ description: 'Хэш приложения к проекту' })
@IsString()
appendix_hash!: string;
}
@InputType(`GenerationConvertStatementSignedDocumentInput`)
export class GenerationConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => GenerationConvertStatementSignedMetaDocumentInputDTO, {
description: 'Метаинформация для документа заявления о конвертации целевого паевого взноса',
})
public readonly meta!: GenerationConvertStatementSignedMetaDocumentInputDTO;
}
@@ -1,64 +0,0 @@
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
import { IsString, IsBoolean } from 'class-validator';
import { Cooperative } from 'cooptypes';
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
// утилита для выборки повторяющихся параметров из базовых интерфейсов
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id'>;
// интерфейс параметров для генерации
type action = Cooperative.Registry.GenerationToMainWalletConvertStatement.Action;
@InputType(`BaseGenerationToMainWalletConvertStatementMetaDocumentInput`)
class BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
@Field({ description: 'Хэш приложения' })
@IsString()
appendix_hash!: string;
@Field({ description: 'Хэш проекта' })
@IsString()
project_hash!: string;
@Field({ description: 'Сумма для перевода на основной кошелек' })
@IsString()
main_wallet_amount!: string;
@Field({ description: 'Сумма для перевода на благорост кошелек' })
@IsString()
blagorost_wallet_amount!: string;
@Field({ description: 'Перевод на основной кошелек' })
@IsBoolean()
to_wallet!: boolean;
@Field({ description: 'Перевод на благорост кошелек' })
@IsBoolean()
to_blagorost!: boolean;
}
@InputType(`GenerationToMainWalletConvertStatementGenerateDocumentInput`)
export class GenerationToMainWalletConvertStatementGenerateDocumentInputDTO extends IntersectionType(
BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO,
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
) {
registry_id!: number;
constructor() {
super();
}
}
@InputType(`GenerationToMainWalletConvertStatementSignedMetaDocumentInput`)
export class GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO
extends IntersectionType(BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
implements action {}
@InputType(`GenerationToMainWalletConvertStatementSignedDocumentInput`)
export class GenerationToMainWalletConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
@Field(() => GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO, {
description: 'Метаинформация для документа заявления о переводе части целевого паевого взноса',
})
public readonly meta!: GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO;
}
@@ -0,0 +1,34 @@
import { Field, InputType } from '@nestjs/graphql';
@InputType('CompleteExtensionOnboardingStepInput')
export class CompleteExtensionOnboardingStepInputDTO {
@Field(() => String, { description: 'Имя расширения' })
extension_name!: string;
@Field(() => String, { description: 'Ключ шага из реестра онбординга' })
step_key!: string;
@Field(() => String, {
nullable: true,
description: 'Заголовок предлагаемого совету решения (опционально, если не задано — берётся default_title шага)',
})
title?: string;
@Field(() => String, {
nullable: true,
description: 'Вопрос предлагаемого совету решения (для generator=free_decision)',
})
question?: string;
@Field(() => String, {
nullable: true,
description: 'Текст принимаемого решения (для generator=free_decision)',
})
decision?: string;
@Field(() => String, {
nullable: true,
description: 'Hash повестки общего собрания (для generator=meet)',
})
proposal_hash?: string;
}
@@ -0,0 +1,40 @@
import { Field, ObjectType, Int } from '@nestjs/graphql';
@ObjectType('ExtensionOnboardingStepState')
export class ExtensionOnboardingStepStateDTO {
@Field(() => String, { description: 'Идентификатор шага (соответствует config-полю onboarding_<step_key>_done)' })
step_key!: string;
@Field(() => Boolean, { description: 'Завершён ли шаг (ратифицирован)' })
done!: boolean;
@Field(() => String, { nullable: true, description: 'Hash опубликованного документа шага' })
hash!: string | null;
@Field(() => Int, { description: 'Порядок отображения' })
order!: number;
@Field(() => String, {
nullable: true,
description: 'Заголовок шага по умолчанию (если payload не передал свой)',
})
default_title!: string | null;
}
@ObjectType('ExtensionOnboardingState')
export class ExtensionOnboardingStateDTO {
@Field(() => String, { description: 'Имя расширения' })
extension_name!: string;
@Field(() => [ExtensionOnboardingStepStateDTO], { description: 'Список шагов онбординга в порядке отображения' })
steps!: ExtensionOnboardingStepStateDTO[];
@Field(() => String, { description: 'Момент старта онбординга (ISO)' })
onboarding_init_at!: string;
@Field(() => String, { description: 'Момент истечения TTL онбординга (ISO)' })
onboarding_expire_at!: string;
@Field(() => Boolean, { description: 'Все шаги завершены' })
all_done!: boolean;
}
@@ -0,0 +1,2 @@
export * from './extension-onboarding-state.dto';
export * from './complete-extension-onboarding-step.input';
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ExtensionOnboardingResolver } from './resolvers/extension-onboarding.resolver';
/**
* Application-модуль платформенного онбординга расширений: содержит
* GraphQL-резолвер. ExtensionOnboardingService доступен глобально из
* OnboardingDomainModule.
*/
@Module({
providers: [ExtensionOnboardingResolver],
})
export class OnboardingApplicationModule {}
@@ -0,0 +1,65 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { ExtensionOnboardingService } from '~/domain/onboarding/services/extension-onboarding.service';
import { ExtensionOnboardingStateDTO } from '../dto/extension-onboarding-state.dto';
import { CompleteExtensionOnboardingStepInputDTO } from '../dto/complete-extension-onboarding-step.input';
/**
* Универсальный GraphQL-резолвер платформенного онбординга кооператива
* на расширение. Любое расширение, зарегистрировавшее шаги через
* ONBOARDING_STEP_REGISTRATION_PORT в initialize(), сразу же
* пользуется этим endpoint'ом без своего DTO/резолвера.
*
* Query open для chairman/member/user — frontend (включая обычного
* пайщика) должен уметь увидеть, что кооператив ещё не онбординулся.
* Mutation — только chairman (онбординг кооператива — компетенция
* председателя).
*/
@Resolver()
export class ExtensionOnboardingResolver {
constructor(private readonly onboardingService: ExtensionOnboardingService) {}
@Query(() => ExtensionOnboardingStateDTO, {
name: 'getExtensionOnboardingState',
description: 'Получить состояние онбординга кооператива на расширение',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member', 'user'])
async getState(
@Args('extension_name', { type: () => String }) extension_name: string
): Promise<ExtensionOnboardingStateDTO> {
const state = await this.onboardingService.getState(extension_name);
return state as ExtensionOnboardingStateDTO;
}
@Mutation(() => ExtensionOnboardingStateDTO, {
name: 'completeExtensionOnboardingStep',
description:
'Выполнить шаг онбординга кооператива на расширение (решение совета или общее собрание)',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async completeStep(
@Args('data', { type: () => CompleteExtensionOnboardingStepInputDTO })
data: CompleteExtensionOnboardingStepInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<ExtensionOnboardingStateDTO> {
const state = await this.onboardingService.completeStep(
{
extension_name: data.extension_name,
step_key: data.step_key,
title: data.title,
question: data.question,
decision: data.decision,
proposal_hash: data.proposal_hash,
},
currentUser?.username
);
return state as ExtensionOnboardingStateDTO;
}
}
@@ -262,7 +262,13 @@ export class ParticipantInteractor {
}
/**
* Преобразует AgreementId в соответствующий DocumentType
* Преобразует agreementId в соответствующий DocumentType.
*
* Платформенные оферты (signature/wallet/user/privacy) сопоставляются
* по AgreementId enum явно. Оферты расширений (например 'blagorost_offer',
* 'generator_offer' от capital) сопоставляются identity-фолбэком —
* строковое значение agreementId должно совпасть с одним из значений
* DocumentType. Это позволяет ядру не знать о капитал-специфике.
*/
private mapAgreementIdToDocumentType(agreementId: string): DocumentType | null {
switch (agreementId) {
@@ -274,14 +280,15 @@ export class ParticipantInteractor {
return DocumentType.USER_AGREEMENT;
case AgreementId.PRIVACY_AGREEMENT:
return DocumentType.PRIVACY_AGREEMENT;
case AgreementId.BLAGOROST_OFFER:
return DocumentType.BLAGOROST_OFFER;
case AgreementId.GENERATOR_OFFER:
return DocumentType.GENERATOR_OFFER;
default:
this.logger.warn(`Неизвестный agreementId: ${agreementId}`);
return null;
}
const documentTypeValues = Object.values(DocumentType) as string[];
if (documentTypeValues.includes(agreementId)) {
return agreementId as DocumentType;
}
this.logger.warn(`Неизвестный agreementId: ${agreementId}`);
return null;
}
async addParticipant(data: AddParticipantDomainInterface): Promise<AccountDomainEntity> {
@@ -0,0 +1,77 @@
import { ObjectType, Field, Int } from '@nestjs/graphql';
import { IsArray, IsBoolean, IsNumber, IsString } from 'class-validator';
import { AccountType } from '~/application/account/enum/account-type.enum';
import type { IAgreementConfigItem } from '~/domain/registration/config/agreement-config.interface';
/**
* GraphQL DTO спецификации оферты, требуемой при регистрации.
*
* Сливает платформенные оферты (signature/wallet/user/privacy) с
* extension-зарегистрированными (например, capital → generator_offer,
* blagorost_offer) — для SignUp на фронте конфигурация неотличима.
*
* Не путать с `AgreementDTO` (это уже on-chain подписанное соглашение
* конкретного пайщика). Здесь — описание чекбокса и его документа,
* который ещё предстоит сгенерировать и подписать.
*/
@ObjectType('RegistrationAgreement', {
description: 'Описание оферты, которую пайщик должен принять при регистрации',
})
export class RegistrationAgreementDTO {
@Field({ description: 'Строковый идентификатор оферты' })
@IsString()
id!: string;
@Field({ description: 'Тип соглашения для on-chain sendAgreement' })
@IsString()
agreement_type!: string;
@Field(() => Int, { description: 'registry_id шаблона на фабрике документов' })
@IsNumber()
registry_id!: number;
@Field({ description: 'Человекочитаемое название оферты' })
@IsString()
title!: string;
@Field({ description: 'Текст для галочки' })
@IsString()
checkbox_text!: string;
@Field({ description: 'Текст ссылки для открытия диалога чтения' })
@IsString()
link_text!: string;
@Field({ description: 'Нужно ли отправлять в блокчейн через sendAgreement' })
@IsBoolean()
is_blockchain_agreement!: boolean;
@Field({ description: 'Нужно ли линковать хеш документа в заявление' })
@IsBoolean()
link_to_statement!: boolean;
@Field(() => [AccountType], {
description: 'Типы аккаунтов, для которых оферта применима',
})
@IsArray()
applicable_account_types!: AccountType[];
@Field(() => Int, { description: 'Порядок отображения' })
@IsNumber()
order!: number;
constructor(data?: IAgreementConfigItem) {
if (data) {
this.id = data.id;
this.agreement_type = data.agreement_type;
this.registry_id = data.registry_id;
this.title = data.title;
this.checkbox_text = data.checkbox_text;
this.link_text = data.link_text;
this.is_blockchain_agreement = data.is_blockchain_agreement;
this.link_to_statement = data.link_to_statement;
this.applicable_account_types = data.applicable_account_types;
this.order = data.order;
}
}
}
@@ -20,6 +20,8 @@ import { GatewayPaymentDTO } from '~/application/gateway/dto/gateway-payment.dto
import { CreateInitialPaymentInputDTO } from '~/application/gateway/dto/create-initial-payment-input.dto';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { RegistrationAgreementDTO } from '../dto/registration-agreement.dto';
import { AccountType } from '~/application/account/enum/account-type.enum';
// Пагинированные результаты
const paginatedCandidatesResult = createPaginationResult(CandidateOutputDTO, 'PaginatedCandidates');
@@ -47,6 +49,24 @@ export class RegistrationResolver {
return await this.registrationService.getCandidates(currentUser, filter, options);
}
/**
* Список оферт, требуемых при регистрации для данного типа аккаунта
* (и опционально программы). Сливает платформенные + extension-
* зарегистрированные через AgreementRegistry.
*/
@Query(() => [RegistrationAgreementDTO], {
name: 'getRegistrationAgreements',
description:
'Получить список оферт для регистрации пайщика заданного типа аккаунта и (опционально) программы. Сливает базовые платформенные оферты с теми, что зарегистрировали расширения.',
})
async getRegistrationAgreements(
@Args('coopname', { type: () => String }) coopname: string,
@Args('account_type', { type: () => AccountType }) accountType: AccountType,
@Args('program_key', { type: () => String, nullable: true }) programKey?: string
): Promise<RegistrationAgreementDTO[]> {
return this.registrationService.getRegistrationAgreements(coopname, accountType, programKey);
}
@Mutation(() => GenerateRegistrationDocumentsOutputDTO, {
name: 'generateRegistrationDocuments',
description:
@@ -22,6 +22,9 @@ import { GeneratedDocumentDTO } from '~/application/document/dto/generated-docum
import { ParticipantApplicationDecisionGenerateDocumentInputDTO } from '~/application/document/documents-dto/participant-application-decision-document.dto';
import { ACCOUNT_DATA_PORT, AccountDataPort } from '~/domain/account/ports/account-data.port';
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { AGREEMENT_QUERY_PORT, AgreementQueryPort } from '~/domain/registration/ports/agreement-query.port';
import { RegistrationAgreementDTO } from '../dto/registration-agreement.dto';
import { AccountType } from '~/application/account/enum/account-type.enum';
@Injectable()
export class RegistrationService {
@@ -35,9 +38,25 @@ export class RegistrationService {
@Inject(USER_CERTIFICATE_DOMAIN_PORT)
private readonly userCertificateDomainPort: UserCertificateDomainPort,
@Inject(ACCOUNT_DATA_PORT)
private readonly accountDataPort: AccountDataPort
private readonly accountDataPort: AccountDataPort,
@Inject(AGREEMENT_QUERY_PORT)
private readonly agreementQueryPort: AgreementQueryPort
) {}
/**
* Возвращает список оферт, которые пайщик должен принять при
* регистрации (сливает платформенные + extension-зарегистрированные).
*/
getRegistrationAgreements(
coopname: string,
accountType: AccountType,
programKey?: string
): RegistrationAgreementDTO[] {
return this.agreementQueryPort
.getAgreementsForAccountType(accountType, coopname, programKey)
.map((item) => new RegistrationAgreementDTO(item));
}
/**
* Генерация пакета документов для регистрации
*/
@@ -1,5 +1,5 @@
import { Field, InputType } from '@nestjs/graphql';
import { ValidateNested } from 'class-validator';
import { IsBoolean, IsOptional, ValidateNested } from 'class-validator';
import { CreateInitOrganizationDataInputDTO } from '~/application/account/dto/create-organization-data-input.dto';
@InputType('Init')
@@ -9,4 +9,13 @@ export class InitDTO {
})
@ValidateNested()
organization_data!: CreateInitOrganizationDataInputDTO;
@Field(() => Boolean, {
nullable: true,
description:
'Признак того, что инициализация выполняется со стороны провайдера. При true coopback ставит init_by_server=true (org_data становится readonly для пользовательского визарда). Поле передаёт provider в callInitSystemMutation.',
})
@IsBoolean()
@IsOptional()
is_server_init?: boolean;
}
@@ -27,10 +27,14 @@ export class InitInteractor {
}
// Определяем тип инициализации:
// - Если mono не существует - это ПЕРВАЯ инициализация (может быть как серверная так и пользовательская)
// - Если existingMono.init_by_server уже установлен - сохраняем его (не меняем источник)
// - Если mono существует но init_by_server не установлен - значит это пользовательская инициализация
const isServerInit = !existingMono ? true : existingMono.init_by_server === true;
// - Если data.is_server_init === true (вызов от провайдера через server-secret) — ВСЕГДА серверная.
// Это разблокирует перезапись user-init данных провайдером при следующем initSystem.
// - Иначе сохраняем прежнюю логику: первая инициализация = серверная, повторная — наследует флаг.
const isServerInit = data.is_server_init === true
? true
: !existingMono
? true
: existingMono.init_by_server === true;
// Проверяем права на обновление:
// Пользователь не может обновлять данные, установленные сервером
@@ -58,11 +62,11 @@ export class InitInteractor {
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
}
// Устанавливаем флаг источника инициализации только если это первая инициализация
// - Для серверной инициализации (первый раз): устанавливаем true
// - Для пользовательской инициализации (первый раз): устанавливаем false
// - При повторной инициализации: НЕ меняем флаг (сохраняем изначальный источник)
if (!existingMono) {
// Устанавливаем флаг источника инициализации:
// - Первая инициализация: ставим isServerInit как есть (true для server-side, true для user-side при первом init — историческое поведение).
// - Повторная: если is_server_init=true пришёл от провайдера — поднимаем флаг в true (даже если до этого было user-init).
// Это нужно чтобы провайдер мог перезаписать данные после того как пользователь успел заполнить форму первым.
if (!existingMono || data.is_server_init === true) {
await this.monoStatusRepository.setInitByServer(isServerInit);
}
@@ -110,19 +110,10 @@ export class SystemService {
* @returns конфигурация программ регистрации
*/
public async getRegistrationConfig(coopname: string, accountType: AccountType): Promise<RegistrationConfigDTO> {
const config = this.agreementConfigService.getCooperativeProgramsConfig(coopname);
if (!config) {
return new RegistrationConfigDTO({
requires_selection: false,
programs: [],
});
}
const programs = this.agreementConfigService.getAvailablePrograms(coopname, accountType);
return new RegistrationConfigDTO({
requires_selection: config.requires_selection && programs.length > 1,
requires_selection: programs.length > 1,
programs,
});
}
+33 -2
View File
@@ -93,8 +93,9 @@ const envVarsSchema = z.object({
.default('https://союз-русь.рф/anketa')
.describe('ссылка на анкету для получения членства в союзе кооперативов'),
IS_UNIONED: z
.boolean()
.default(true)
.string()
.default('true')
.transform((v) => v === 'true')
.describe('флаг, указывающий что требуется членство в союзе для подключения к кооперативной экономике'),
MATRIX_UNION_PERSON_ID: z.string().optional().describe('Matrix userId представителя союза для связи с кооперативами'),
MATRIX_UNION_NAME: z.string().default('СПО РУСЬ').describe('Название союза для подписания комнат связи'),
@@ -170,6 +171,28 @@ const envVarsSchema = z.object({
OPENAI_BASE_URL: z.string().optional().describe('Базовый URL для Whisper API (через chatcoop-proxy nginx)'),
WHISPER_MODEL: z.string().default('whisper-1').describe('Модель Whisper для STT'),
WHISPER_LANGUAGE: z.string().default('ru').describe('Язык для Whisper STT'),
// Файловое хранилище (MinIO в dev/контуре кооператива; S3 в проде по плану E59-N).
// MINIO_ENDPOINT без default: если не задан — file storage стартует в no-op,
// HeadBucket не делается, операции get/put отдают понятную ошибку.
MINIO_ENDPOINT: z
.string()
.optional()
.describe('Endpoint S3-совместимого бэкенда; в compose — service name. Пусто = file storage отключён.'),
MINIO_ACCESS_KEY: z.string().default('minioadmin').describe('Access-key для MinIO/S3'),
MINIO_SECRET_KEY: z.string().default('minioadmin').describe('Secret-key для MinIO/S3'),
MINIO_BUCKET: z
.string()
.optional()
.describe('Имя физического бакета; по умолчанию `coop-${COOPNAME}`'),
FILE_STORAGE_SIGNING_SECRET: z
.string()
.optional()
.describe('HMAC-секрет для подписи read-URL; пусто — берётся SERVER_SECRET'),
FILE_STORAGE_PUBLIC_BASE_URL: z
.string()
.optional()
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
});
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
@@ -293,4 +316,12 @@ export default {
whisper_model: envVars.data.WHISPER_MODEL,
whisper_language: envVars.data.WHISPER_LANGUAGE,
},
file_storage: {
endpoint: envVars.data.MINIO_ENDPOINT,
access_key: envVars.data.MINIO_ACCESS_KEY,
secret_key: envVars.data.MINIO_SECRET_KEY,
bucket: envVars.data.MINIO_BUCKET || `coop-${envVars.data.COOPNAME}`,
signing_secret: envVars.data.FILE_STORAGE_SIGNING_SECRET || envVars.data.SERVER_SECRET,
public_base_url: envVars.data.FILE_STORAGE_PUBLIC_BASE_URL || envVars.data.BACKEND_URL,
},
};
@@ -1,7 +1,7 @@
// domain/appstore/appstore-lifecycle-domain.service.ts
import { Injectable, type INestApplication } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { ExtensionDomainService } from '~/domain/extension/services/extension-domain.service';
import { ExtensionSchemaMigrationService } from './extension-schema-migration.service';
import { AppRegistry } from '~/extensions/extensions.registry';
@@ -10,6 +10,10 @@ import {
EXTENSION_APP_TERMINATE_EVENT,
type ExtensionAppTerminatePayload,
} from '~/domain/extension/extension-app-lifecycle.events';
import {
ONBOARDING_COMPLETED_EVENT,
type OnboardingCompletedPayload,
} from '~/domain/onboarding/events/onboarding-completed.event';
@Injectable()
export class ExtensionLifecycleDomainService<TConfig = any> {
@@ -106,4 +110,31 @@ export class ExtensionLifecycleDomainService<TConfig = any> {
await this.runApp(appName);
this.logger.info(`Расширение ${appName} перезапущено.`);
}
/**
* Авто-перезапуск расширения после завершения его L1-онбординга.
*
* Логика — раздел 7.1 плана C28-10 (вариант (в) auto-restart): сервис
* онбординга расширения эмиттит ONBOARDING_COMPLETED_EVENT, когда все
* _done флаги в extension.config встали; lifecycle ловит и перезапускает
* расширение, чтобы initialize(config) увидел актуальный config и
* перерегистрировал оферты/программы в AgreementRegistryService.
*
* Совет ничего не нажимает.
*/
@OnEvent(ONBOARDING_COMPLETED_EVENT)
async onOnboardingCompleted(payload: OnboardingCompletedPayload) {
const { extension_name } = payload;
this.logger.info(
`[ONBOARDING_COMPLETED] получено событие для ${extension_name}, инициирую restartApp`
);
try {
await this.restartApp(extension_name);
} catch (error) {
this.logger.error(
`[ONBOARDING_COMPLETED] restartApp(${extension_name}) упал: ${(error as Error).message}`,
(error as Error).stack
);
}
}
}
@@ -0,0 +1,26 @@
/**
* Платформенный TTL для L1-онбординга расширений.
*
* Один источник правды для capital и chairman (оба хардкодили
* 30 * 24 * 60 * 60 * 1000 в своих модулях). При первом запуске
* расширения сервис записывает `onboarding_expire_at` в config
* плагина = startedAt + ONBOARDING_EXPIRY_MS; по истечении срока
* `*-onboarding-events.service.isOnboardingExpired` возвращает true
* и UI/процесс реагирует соответственно.
*
* Перенос в общий модуль (план C28-10 раздел 4.2) — чтобы изменение
* политики (например, продление до 45 дней) не требовало правки
* двух модулей и не дрейфовало по копиям.
*/
export const ONBOARDING_EXPIRY_DAYS = 30;
export const ONBOARDING_EXPIRY_MS = ONBOARDING_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
/**
* Время истечения L1-онбординга, отталкиваясь от момента старта.
* Возвращает ISO-строку, потому что в config'е плагина хранится строкой.
*/
export function computeOnboardingExpiresAt(startedAt: Date): string {
return new Date(startedAt.getTime() + ONBOARDING_EXPIRY_MS).toISOString();
}
@@ -0,0 +1,13 @@
export type OnboardingStepEventType = 'SOVIET_DECISION' | 'MEET_DECISION';
export type OnboardingStepGenerator = 'free_decision' | 'meet';
export interface IExtensionOnboardingStepSpec {
extension_name: string;
step_key: string;
event_type: OnboardingStepEventType;
vars_field: string;
generator: OnboardingStepGenerator;
default_title?: string;
order: number;
}
@@ -0,0 +1 @@
export * from './extension-onboarding-step-spec';
@@ -0,0 +1,19 @@
/**
* Событие: расширение завершило L1-онбординг
* (все его шаги в extension.config.* проставлены в _done = true).
*
* Эмиттится сервисом онбординга расширения сразу после успешного
* `completeStep`, когда последний `_done` флаг встал. Слушатель —
* ExtensionLifecycleDomainService — автоматически вызывает
* `restartApp(extension_name)`, чтобы расширение перерегистрировало
* свои оферты/программы через `initialize(config)` с актуальным
* содержимым extension.config (раздел 4.2 req 44 проекта 13;
* принятая архитектурная предпосылка 7.1 в плане C28-10:
* вариант (в) — auto-restart, без ручного действия совета).
*/
export const ONBOARDING_COMPLETED_EVENT = 'onboarding.completed' as const;
export interface OnboardingCompletedPayload {
/** Имя расширения, у которого завершился L1 (совпадает с appName в AppRegistry). */
extension_name: string;
}
@@ -0,0 +1,6 @@
export * from './dto';
export * from './ports';
export * from './services';
export * from './onboarding-domain.module';
export * from './constants/onboarding-ttl';
export * from './events/onboarding-completed.event';
@@ -0,0 +1,42 @@
import { Global, Module } from '@nestjs/common';
import { ExtensionDomainModule } from '~/domain/extension/extension-domain.module';
import { FreeDecisionInfrastructureModule } from '~/infrastructure/free-decision/free-decision-infrastructure.module';
import { DecisionTrackingInfrastructureModule } from '~/infrastructure/decision-tracking/decision-tracking-infrastructure.module';
import { OnboardingStepsRegistryService } from './services/onboarding-steps-registry.service';
import { ExtensionOnboardingService } from './services/extension-onboarding.service';
import { ExtensionOnboardingEventsService } from './services/extension-onboarding-events.service';
import { ONBOARDING_STEP_REGISTRATION_PORT } from './ports/onboarding-step-registration.port';
import { ONBOARDING_STEP_QUERY_PORT } from './ports/onboarding-step-query.port';
/**
* Глобальный domain-модуль платформенного онбординга кооператива на
* расширение.
*/
@Global()
@Module({
imports: [
ExtensionDomainModule,
FreeDecisionInfrastructureModule,
DecisionTrackingInfrastructureModule,
],
providers: [
OnboardingStepsRegistryService,
{
provide: ONBOARDING_STEP_REGISTRATION_PORT,
useExisting: OnboardingStepsRegistryService,
},
{
provide: ONBOARDING_STEP_QUERY_PORT,
useExisting: OnboardingStepsRegistryService,
},
ExtensionOnboardingService,
ExtensionOnboardingEventsService,
],
exports: [
OnboardingStepsRegistryService,
ONBOARDING_STEP_REGISTRATION_PORT,
ONBOARDING_STEP_QUERY_PORT,
ExtensionOnboardingService,
],
})
export class OnboardingDomainModule {}
@@ -0,0 +1,2 @@
export * from './onboarding-step-registration.port';
export * from './onboarding-step-query.port';
@@ -0,0 +1,11 @@
import type { IExtensionOnboardingStepSpec } from '../dto/extension-onboarding-step-spec';
export interface OnboardingStepQueryPort {
getStepsByExtension(extension_name: string): IExtensionOnboardingStepSpec[];
getStep(
extension_name: string,
step_key: string
): IExtensionOnboardingStepSpec | null;
}
export const ONBOARDING_STEP_QUERY_PORT = Symbol('OnboardingStepQueryPort');
@@ -0,0 +1,10 @@
import type { IExtensionOnboardingStepSpec } from '../dto/extension-onboarding-step-spec';
export interface OnboardingStepRegistrationPort {
registerStep(spec: IExtensionOnboardingStepSpec): void;
unregisterStepsByExtension(extension_name: string): void;
}
export const ONBOARDING_STEP_REGISTRATION_PORT = Symbol(
'OnboardingStepRegistrationPort'
);
@@ -0,0 +1,106 @@
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import {
EXTENSION_REPOSITORY,
type ExtensionDomainRepository,
} from '~/domain/extension/repositories/extension-domain.repository';
import { DecisionTrackedEvent } from '~/domain/decision-tracking/events/decision-tracked.event';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import {
ONBOARDING_COMPLETED_EVENT,
type OnboardingCompletedPayload,
} from '../events/onboarding-completed.event';
import {
ONBOARDING_STEP_QUERY_PORT,
type OnboardingStepQueryPort,
} from '../ports/onboarding-step-query.port';
/**
* Расширения с собственным per-extension events-сервисом
* (см. {Capital,Chairman}OnboardingEventsService). Generic-слушатель
* пропускает их, чтобы избежать double-update флага и double-emit
* ONBOARDING_COMPLETED. После удаления legacy сервисов в одном из
* следующих эпиков (когда capital/chairman перестанут хардкодить свои
* step-mapping'и) этот список схлопнётся до пустого.
*/
const LEGACY_EXTENSIONS_WITH_OWN_LISTENER: ReadonlyArray<string> = [
'chairman',
'capital',
];
const doneKey = (step_key: string) => `onboarding_${step_key}_done`;
@Injectable()
export class ExtensionOnboardingEventsService {
constructor(
@Inject(EXTENSION_REPOSITORY)
private readonly extensionRepository: ExtensionDomainRepository<
Record<string, unknown>
>,
@Inject(ONBOARDING_STEP_QUERY_PORT)
private readonly stepsRegistry: OnboardingStepQueryPort,
private readonly logger: WinstonLoggerService,
private readonly eventEmitter: EventEmitter2
) {
this.logger.setContext(ExtensionOnboardingEventsService.name);
}
@OnEvent(DecisionTrackedEvent.eventName)
async handleDecisionTracked(event: DecisionTrackedEvent): Promise<void> {
const { result } = event;
if (!result.metadata?.onboarding_step || !result.metadata.extension) return;
const extension_name = String(result.metadata.extension);
const step_key = String(result.metadata.onboarding_step);
if (LEGACY_EXTENSIONS_WITH_OWN_LISTENER.includes(extension_name)) {
return;
}
const spec = this.stepsRegistry.getStep(extension_name, step_key);
if (!spec) {
this.logger.debug(
`Шаг ${extension_name}/${step_key} не найден в OnboardingStepsRegistry — пропуск`
);
return;
}
try {
const plugin = await this.extensionRepository.findByName(extension_name);
if (!plugin) {
this.logger.warn(`Расширение ${extension_name} не найдено в репозитории`);
return;
}
const flagKey = doneKey(step_key);
const wasAlreadyDone = Boolean(plugin.config[flagKey]);
if (wasAlreadyDone) return;
const updatedConfig: Record<string, unknown> = {
...plugin.config,
[flagKey]: true,
};
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
this.logger.info(`Онбординг ${extension_name}: ${flagKey} = true`);
const specs = this.stepsRegistry.getStepsByExtension(extension_name);
const allDone =
specs.length > 0 &&
specs.every((s) => Boolean(updatedConfig[doneKey(s.step_key)]));
if (allDone) {
this.logger.info(
`[ONBOARDING_COMPLETED] ${extension_name}: все шаги завершены`
);
this.eventEmitter.emit(ONBOARDING_COMPLETED_EVENT, {
extension_name,
} satisfies OnboardingCompletedPayload);
}
} catch (error) {
const err = error as Error;
this.logger.error(
`Ошибка обработки DecisionTrackedEvent для ${extension_name}/${step_key}: ${err.message}`,
err.stack
);
}
}
}
@@ -0,0 +1,266 @@
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import { Cooperative } from 'cooptypes';
import {
EXTENSION_REPOSITORY,
type ExtensionDomainRepository,
} from '~/domain/extension/repositories/extension-domain.repository';
import {
FREE_DECISION_PORT,
type FreeDecisionPort,
} from '~/domain/free-decision/ports/free-decision.port';
import {
DECISION_TRACKING_PORT,
type DecisionTrackingPort,
} from '~/domain/decision-tracking/ports/decision-tracking.port';
import { DecisionEventType } from '~/domain/decision-tracking/interfaces/tracking-rule-domain.interface';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import { computeOnboardingExpiresAt } from '../constants/onboarding-ttl';
import {
ONBOARDING_STEP_QUERY_PORT,
type OnboardingStepQueryPort,
} from '../ports/onboarding-step-query.port';
import type { IExtensionOnboardingStepSpec } from '../dto/extension-onboarding-step-spec';
import config from '~/config/config';
export interface IExtensionOnboardingStepState {
step_key: string;
done: boolean;
hash: string | null;
order: number;
default_title: string | null;
}
export interface IExtensionOnboardingState {
extension_name: string;
steps: IExtensionOnboardingStepState[];
onboarding_init_at: string;
onboarding_expire_at: string;
all_done: boolean;
}
export interface ICompleteExtensionOnboardingStepInput {
extension_name: string;
step_key: string;
title?: string;
/** Текст вопроса предлагаемого решения. Обязателен для generator=free_decision. */
question?: string;
/** Текст принимаемого решения. Обязателен для generator=free_decision. */
decision?: string;
/** Hash повестки общего собрания. Обязателен для generator=meet. */
proposal_hash?: string;
}
const doneKey = (step_key: string) => `onboarding_${step_key}_done`;
const hashKey = (step_key: string) => `onboarding_${step_key}_hash`;
/**
* Generic-сервис онбординга кооператива на расширение.
*
* Шаги задаются расширением декларативно через
* OnboardingStepsRegistry в момент initialize(). Платформенный слой
* выполняет общий flow (free-decision/meet → tracking-rule →
* сохранение hash в extension.config) и собирает state из полей
* `onboarding_<step_key>_done` / `_hash` — те же ключи, что
* используют legacy chairman/capital, чтобы старые и новые resolver'ы
* сходились на единой config-истине.
*/
@Injectable()
export class ExtensionOnboardingService {
constructor(
@Inject(EXTENSION_REPOSITORY)
private readonly extensionRepository: ExtensionDomainRepository<
Record<string, unknown>
>,
@Inject(FREE_DECISION_PORT)
private readonly freeDecisionPort: FreeDecisionPort,
@Inject(DECISION_TRACKING_PORT)
private readonly decisionTrackingPort: DecisionTrackingPort,
@Inject(ONBOARDING_STEP_QUERY_PORT)
private readonly stepsRegistry: OnboardingStepQueryPort
) {}
private async loadPlugin(extension_name: string) {
const plugin = await this.extensionRepository.findByName(extension_name);
if (!plugin) {
throw new Error(`Расширение не найдено: ${extension_name}`);
}
const pluginConfig: Record<string, unknown> = { ...plugin.config };
let needUpdate = false;
if (!pluginConfig.onboarding_init_at) {
pluginConfig.onboarding_init_at = new Date().toISOString();
needUpdate = true;
}
if (!pluginConfig.onboarding_expire_at) {
const start = new Date(pluginConfig.onboarding_init_at as string);
pluginConfig.onboarding_expire_at = computeOnboardingExpiresAt(start);
needUpdate = true;
}
if (needUpdate) {
await this.extensionRepository.update({ ...plugin, config: pluginConfig });
}
return { ...plugin, config: pluginConfig };
}
public async getState(
extension_name: string
): Promise<IExtensionOnboardingState> {
const plugin = await this.loadPlugin(extension_name);
const specs = this.stepsRegistry.getStepsByExtension(extension_name);
const steps: IExtensionOnboardingStepState[] = specs.map((spec) => ({
step_key: spec.step_key,
done: Boolean(plugin.config[doneKey(spec.step_key)]),
hash:
(plugin.config[hashKey(spec.step_key)] as string | undefined) || null,
order: spec.order,
default_title: spec.default_title ?? null,
}));
return {
extension_name,
steps,
onboarding_init_at:
(plugin.config.onboarding_init_at as string | undefined) || '',
onboarding_expire_at:
(plugin.config.onboarding_expire_at as string | undefined) || '',
all_done: steps.length > 0 && steps.every((s) => s.done),
};
}
public async completeStep(
input: ICompleteExtensionOnboardingStepInput,
username: string
): Promise<IExtensionOnboardingState> {
const spec = this.stepsRegistry.getStep(
input.extension_name,
input.step_key
);
if (!spec) {
throw new Error(
`Шаг онбординга не зарегистрирован: ${input.extension_name}/${input.step_key}`
);
}
const plugin = await this.loadPlugin(input.extension_name);
if (plugin.config[doneKey(spec.step_key)]) {
return this.getState(input.extension_name);
}
const storedHash = await this.runGenerator(spec, input, username);
const updatedConfig: Record<string, unknown> = {
...plugin.config,
[hashKey(spec.step_key)]: storedHash,
};
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
return this.getState(input.extension_name);
}
private async runGenerator(
spec: IExtensionOnboardingStepSpec,
input: ICompleteExtensionOnboardingStepInput,
username: string
): Promise<string> {
if (spec.generator === 'free_decision') {
return this.runFreeDecisionGenerator(spec, input, username);
}
if (spec.generator === 'meet') {
return this.runMeetGenerator(spec, input);
}
throw new Error(
`Неизвестный generator шага онбординга: ${String(spec.generator)}`
);
}
private async runFreeDecisionGenerator(
spec: IExtensionOnboardingStepSpec,
input: ICompleteExtensionOnboardingStepInput,
username: string
): Promise<string> {
if (!input.question || !input.decision) {
throw new Error(
`Шаг ${spec.extension_name}/${spec.step_key} (generator='free_decision') требует question и decision`
);
}
const normalizedTitle =
input.title?.trim().substring(0, 200) || spec.default_title;
const project_id = uuid();
await this.freeDecisionPort.createProjectOfFreeDecision({
id: project_id,
title: normalizedTitle,
question: input.question,
decision: input.decision,
});
const generatedDoc =
await this.freeDecisionPort.generateProjectOfFreeDecisionDocument(
{
project_id,
coopname: config.coopname,
username,
registry_id: Cooperative.Registry.ProjectFreeDecision.registry_id,
title: normalizedTitle,
},
{}
);
const documentForPublish: ISignedDocumentDomainInterface = {
version: (generatedDoc.meta as any)?.version || '1.0',
hash: generatedDoc.hash,
doc_hash: (generatedDoc.meta as any)?.doc_hash || generatedDoc.hash,
meta_hash: (generatedDoc.meta as any)?.meta_hash || generatedDoc.hash,
meta: generatedDoc.meta,
signatures: (generatedDoc.meta as any)?.signatures || [],
};
await this.freeDecisionPort.publishProjectOfFreeDecision({
coopname: config.coopname,
username,
meta: JSON.stringify({
step: spec.step_key,
project_id,
title: normalizedTitle,
extension: input.extension_name,
}),
document: documentForPublish,
});
await this.decisionTrackingPort.registerTrackingRule({
hash: generatedDoc.hash,
event_type: DecisionEventType.SOVIET_DECISION,
vars_field: spec.vars_field,
metadata: {
onboarding_step: spec.step_key,
project_id,
extension: input.extension_name,
},
});
return generatedDoc.hash;
}
private async runMeetGenerator(
spec: IExtensionOnboardingStepSpec,
input: ICompleteExtensionOnboardingStepInput
): Promise<string> {
if (!input.proposal_hash) {
throw new Error(
`Шаг ${spec.extension_name}/${spec.step_key} (generator='meet') требует proposal_hash`
);
}
await this.decisionTrackingPort.registerTrackingRule({
hash: input.proposal_hash,
event_type: DecisionEventType.MEET_DECISION,
vars_field: spec.vars_field,
metadata: {
onboarding_step: spec.step_key,
extension: input.extension_name,
},
});
return input.proposal_hash;
}
}
@@ -0,0 +1,3 @@
export * from './onboarding-steps-registry.service';
export * from './extension-onboarding.service';
export * from './extension-onboarding-events.service';
@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import type { IExtensionOnboardingStepSpec } from '../dto/extension-onboarding-step-spec';
import type { OnboardingStepRegistrationPort } from '../ports/onboarding-step-registration.port';
import type { OnboardingStepQueryPort } from '../ports/onboarding-step-query.port';
@Injectable()
export class OnboardingStepsRegistryService
implements OnboardingStepRegistrationPort, OnboardingStepQueryPort
{
private readonly steps: IExtensionOnboardingStepSpec[] = [];
public registerStep(spec: IExtensionOnboardingStepSpec): void {
const duplicate = this.steps.find(
(s) =>
s.extension_name === spec.extension_name && s.step_key === spec.step_key
);
if (duplicate) {
throw new Error(
`Шаг онбординга уже зарегистрирован: ${spec.extension_name}/${spec.step_key}`
);
}
this.steps.push({ ...spec });
}
public unregisterStepsByExtension(extension_name: string): void {
for (let i = this.steps.length - 1; i >= 0; i--) {
if (this.steps[i]?.extension_name === extension_name) {
this.steps.splice(i, 1);
}
}
}
public getStepsByExtension(
extension_name: string
): IExtensionOnboardingStepSpec[] {
return this.steps
.filter((s) => s.extension_name === extension_name)
.sort((a, b) => a.order - b.order);
}
public getStep(
extension_name: string,
step_key: string
): IExtensionOnboardingStepSpec | null {
return (
this.steps.find(
(s) => s.extension_name === extension_name && s.step_key === step_key
) ?? null
);
}
}
@@ -1,14 +1,20 @@
import type { AccountType } from '~/application/account/enum/account-type.enum';
import { AgreementType, AgreementId, ProgramKey } from '../enum';
/**
* Конфигурация одного соглашения при регистрации
* Конфигурация одного соглашения при регистрации.
*
* Платформенные оферты (signature/wallet/user/privacy) хранят значения
* `id`/`agreement_type` из ядерных enum'ов AgreementId/AgreementType
* как строки. Оферты, поставляемые расширениями через
* AgreementRegistrationPort, используют собственные строковые значения
* (например 'blagorost_offer' от capital). Поэтому типы — `string`,
* а не enum: ядро не знает значений, заданных расширениями.
*/
export interface IAgreementConfigItem {
/**
* Уникальный идентификатор типа соглашения
*/
id: AgreementId;
id: string;
/**
* registry_id для генерации документа на фабрике документов
@@ -18,7 +24,7 @@ export interface IAgreementConfigItem {
/**
* Тип соглашения для блокчейна
*/
agreement_type: AgreementType;
agreement_type: string;
/**
* Человекочитаемое название документа
@@ -70,8 +76,11 @@ export interface IRegistrationAgreementsConfig {
* Описание программы регистрации с её соглашениями
*/
export interface IRegistrationProgram {
/** Уникальный ключ программы */
key: ProgramKey;
/**
* Уникальный ключ программы. Расширения сами определяют пространство
* имён ключей (например 'GENERATION'/'CAPITALIZATION' от capital).
*/
key: string;
/** Название программы для отображения */
title: string;
@@ -89,22 +98,9 @@ export interface IRegistrationProgram {
applicable_account_types: AccountType[];
/** Список ID соглашений, которые требуются для этой программы */
agreement_ids: AgreementId[];
agreement_ids: string[];
/** Порядок отображения */
order: number;
}
/**
* Конфигурация программ регистрации для кооператива
*/
export interface ICooperativeRegistrationPrograms {
/** Название кооператива */
coopname: string;
/** Доступные программы */
programs: IRegistrationProgram[];
/** Нужен ли выбор программы (если false - используется первая подходящая) */
requires_selection: boolean;
}
@@ -1,3 +1,2 @@
export * from './agreement-config.interface';
export * from './registration-agreements.config';
export * from './registration-programs.config';
@@ -4,11 +4,18 @@ import { AccountType } from '~/application/account/enum/account-type.enum';
import { AgreementType, AgreementId } from '../enum';
/**
* Конфигурация соглашений, требуемых при регистрации пайщика.
* Платформенные соглашения, требуемые при регистрации пайщика в любом
* кооперативе вне зависимости от подключённых расширений.
*
* Порядок (order) определяет последовательность отображения галочек на фронтенде.
* is_blockchain_agreement указывает, нужно ли отправлять sendAgreement в блокчейн.
* link_to_statement указывает, нужно ли линковать хеш документа в заявление.
* Соглашения, специфичные для конкретных расширений (например, оферты
* программ Capital — generator/blagorost), регистрируются самими
* расширениями в `AgreementRegistryService` при инициализации и
* сливаются с этим списком в `AgreementConfigurationService`.
*
* Порядок (order) определяет последовательность отображения галочек
* на фронтенде. is_blockchain_agreement указывает, нужно ли отправлять
* sendAgreement в блокчейн. link_to_statement указывает, нужно ли
* линковать хеш документа в заявление.
*/
export const REGISTRATION_AGREEMENTS_CONFIG: IRegistrationAgreementsConfig = {
agreements: [
@@ -60,29 +67,5 @@ export const REGISTRATION_AGREEMENTS_CONFIG: IRegistrationAgreementsConfig = {
applicable_account_types: [AccountType.individual, AccountType.organization, AccountType.entrepreneur],
order: 4,
},
{
id: AgreementId.BLAGOROST_OFFER,
registry_id: Cooperative.Registry.BlagorostOffer.registry_id,
agreement_type: AgreementType.CAPITAL,
title: 'Оферта по целевой потребительской программе "Благорост"',
checkbox_text: 'Я прочитал и принимаю',
link_text: 'оферту по целевой потребительской программе "Благорост"',
is_blockchain_agreement: true,
link_to_statement: true,
applicable_account_types: [AccountType.individual],
order: 5,
},
{
id: AgreementId.GENERATOR_OFFER,
registry_id: Cooperative.Registry.GeneratorOffer.registry_id,
agreement_type: AgreementType.GENERATOR,
title: 'Оферта по целевой потребительской программе "Генератор"',
checkbox_text: 'Я прочитал и принимаю',
link_text: 'оферту по целевой потребительской программе "Генератор"',
is_blockchain_agreement: true,
link_to_statement: true,
applicable_account_types: [],
order: 6,
},
],
};
@@ -1,34 +0,0 @@
import type { ICooperativeRegistrationPrograms } from './agreement-config.interface';
import { AccountType } from '~/application/account/enum/account-type.enum';
import { ProgramKey, AgreementId } from '../enum';
/**
* Конфигурация программ регистрации для разных кооперативов.
* Здесь определяются доступные программы, их описания и связанные соглашения.
*/
export const REGISTRATION_PROGRAMS_CONFIG: ICooperativeRegistrationPrograms[] = [
{
coopname: 'voskhod',
requires_selection: true,
programs: [
{
key: ProgramKey.GENERATION,
title: 'Программа Генерация',
description:
'Участвовать в производстве Кооперативной Экономики через вклад временем, имуществом или деньгами в конкретные проекты.',
applicable_account_types: [AccountType.individual, AccountType.entrepreneur],
agreement_ids: [AgreementId.GENERATOR_OFFER],
order: 1,
},
{
key: ProgramKey.CAPITALIZATION,
title: 'Программа Благорост',
description:
'Участвовать в производстве Кооперативной Экономики через вклад имуществом или денег в систему. Минимальный взнос 100 000 руб в течение 14 дней.',
applicable_account_types: [AccountType.individual, AccountType.entrepreneur],
agreement_ids: [AgreementId.BLAGOROST_OFFER],
order: 2,
},
],
},
];
@@ -0,0 +1,58 @@
import type { AccountType } from '~/application/account/enum/account-type.enum';
/**
* Спецификация одной оферты, которую расширение регистрирует в платформе
* через AgreementRegistrationPort на этапе initialize(config).
*
* Используется ядром (RegistrationDomain) для построения UI SignUp,
* генерации документов фабрикой по registry_id и отправки sendAgreement
* в блокчейн под валидным agreement_type.
*/
export interface AgreementRegistrationSpec {
/**
* Уникальный идентификатор оферты в рамках платформы.
* Строка, потому что расширение само определяет своё пространство имён
* (например, 'blagorost_offer', 'generator_offer', 'order_table_offer').
*/
id: string;
/**
* registry_id шаблона документа на фабрике — авторитативный источник
* содержимого оферты. Платформа НЕ хранит type-string параллельно,
* см. feedback agreements + document registry_id.
*/
registry_id: number;
/**
* Тип соглашения для on-chain `agreements` (sendAgreement action).
* Расширение предоставляет своё значение (например 'capital' для capital,
* 'order_table' для Стола заказов).
*/
agreement_type: string;
/** Человекочитаемое название оферты для UI */
title: string;
/** Текст для галочки на фронтенде */
checkbox_text: string;
/** Текст ссылки для открытия диалога чтения */
link_text: string;
/**
* Типы аккаунтов, для которых эта оферта применима.
* Пустой массив = «оферта программно сцепляется с программой, не показывается
* как самостоятельный чекбокс в SignUp» (исторически так у GENERATOR_OFFER).
*/
applicable_account_types: AccountType[];
/** Порядок отображения относительно других оферт расширения */
order: number;
/**
* Имя расширения, зарегистрировавшего оферту.
* Используется для tear-down: при EXTENSION_APP_TERMINATE_EVENT
* реестр удаляет все записи с этим extension_name.
*/
extension_name: string;
}
@@ -0,0 +1,2 @@
export * from './agreement-registration-spec.dto';
export * from './program-registration-spec.dto';
@@ -0,0 +1,47 @@
import type { AccountType } from '~/application/account/enum/account-type.enum';
/**
* Спецификация программы участия, которую расширение регистрирует
* через AgreementRegistrationPort.registerProgram() при initialize(config).
*
* Программа предъявляется пайщику в SignUp экране SelectProgram
* (если в кооперативе после фильтрации остаётся >1 программ; 0 — экран
* пропускается, 1 — выбор автоматический).
*/
export interface ProgramRegistrationSpec {
/**
* Уникальный ключ программы в рамках платформы.
* Строка, потому что расширения сами определяют пространство имён.
*/
key: string;
/** Название программы для отображения */
title: string;
/** Описание программы */
description: string;
/** URL изображения (опционально) */
image_url?: string;
/** Минимальные требования (опционально) */
requirements?: string;
/** Для каких типов аккаунтов программа применима */
applicable_account_types: AccountType[];
/**
* Идентификаторы оферт (AgreementRegistrationSpec.id),
* которые должны быть подписаны участником этой программы.
*/
agreement_ids: string[];
/** Порядок отображения в SelectProgram */
order: number;
/**
* Имя расширения, зарегистрировавшего программу.
* Используется для tear-down при EXTENSION_APP_TERMINATE_EVENT.
*/
extension_name: string;
}
@@ -1,5 +1,10 @@
/**
* Идентификаторы соглашений для регистрации
* Идентификаторы платформенных соглашений при регистрации.
*
* Оферты, специфичные для расширений (capital → 'blagorost_offer',
* 'generator_offer' и т.п.), не входят в этот enum: расширения сами
* задают строковые id через AgreementRegistrationPort и являются
* единственным source-of-truth для этих значений.
*/
export enum AgreementId {
/** Соглашение о порядке и правилах использования простой электронной подписи */
@@ -13,10 +18,4 @@ export enum AgreementId {
/** Политика конфиденциальности */
PRIVACY_AGREEMENT = 'privacy_agreement',
/** Соглашение о благороста */
BLAGOROST_OFFER = 'blagorost_offer',
/** Оферта по целевой потребительской программе "Генератор" */
GENERATOR_OFFER = 'generator_offer',
}
@@ -1,5 +1,10 @@
/**
* Типы соглашений для отправки в блокчейн
* Типы платформенных соглашений для отправки в блокчейн (sendAgreement).
*
* Типы оферт расширений (capital → 'capital', 'generator' и т.п.)
* не входят в этот enum: расширения сами задают строковые agreement_type
* через AgreementRegistrationPort и обращаются к sendAgreement напрямую
* с этими значениями.
*/
export enum AgreementType {
/** Соглашение о порядке и правилах использования простой электронной подписи */
@@ -13,10 +18,4 @@ export enum AgreementType {
/** Политика конфиденциальности */
PRIVACY = 'privacy',
/** Соглашение о благороста */
CAPITAL = 'blagorost',
/** Оферта по целевой потребительской программе "Генератор" */
GENERATOR = 'generator',
}
@@ -1,4 +1,6 @@
export * from './config';
export * from './dto';
export * from './interfaces';
export * from './ports';
export * from './services';
export * from './registration-domain.module';
@@ -1,6 +1,5 @@
import type { AccountType } from '~/application/account/enum/account-type.enum';
import type { GeneratedDocumentDomainInterface } from '~/domain/document/interfaces/generated-document-domain.interface';
import { ProgramKey, type AgreementId, type AgreementType } from '../enum';
/**
* Входные данные для генерации пакета документов при регистрации
@@ -12,18 +11,26 @@ export interface IGenerateRegistrationDocumentsInput {
username: string;
/** Тип аккаунта пайщика */
account_type: AccountType;
/** Ключ выбранной программы регистрации (опционально) */
program_key?: ProgramKey;
/**
* Ключ выбранной программы регистрации (опционально).
* Строка, потому что значение задаёт расширение через
* AgreementRegistrationPort.registerProgram.
*/
program_key?: string;
}
/**
* Сгенерированный документ регистрации с метаданными из конфигурации
* Сгенерированный документ регистрации с метаданными из конфигурации.
*
* Поля `id`/`agreement_type` — строки, потому что значения могут
* приходить как из платформенного AgreementId/AgreementType, так и из
* расширений (например 'blagorost_offer' от capital).
*/
export interface IGeneratedRegistrationDocument {
/** Идентификатор соглашения (wallet_agreement, signature_agreement, etc.) */
id: AgreementId;
/** Идентификатор соглашения */
id: string;
/** Тип соглашения для блокчейна */
agreement_type: AgreementType;
agreement_type: string;
/** Название документа */
title: string;
/** Текст для галочки на фронте */
@@ -0,0 +1,57 @@
import type { AccountType } from '~/application/account/enum/account-type.enum';
import type {
IAgreementConfigItem,
IRegistrationProgram,
} from '../config/agreement-config.interface';
/**
* Платформенный порт для чтения зарегистрированных оферт и программ
* (вне зависимости от того, заданы они в ядре или расширением через
* AgreementRegistrationPort).
*
* Резолверы и расширения должны инжектить этот порт (через
* AGREEMENT_QUERY_PORT), а не AgreementConfigurationService напрямую,
* чтобы граница «как читают конфиг» оставалась стабильной даже при
* перестройке внутренней реализации (например, при выносе кеша или
* добавлении coop-scoping в Phase 3).
*
* Контракт чтения, дополняющий AgreementRegistrationPort (запись):
* — оба порта работают со строковыми id/key, не enum;
* — applicable_account_types=[] у оферты означает «не показывать
* как самостоятельный чекбокс в SignUp» (исторический контракт
* GENERATOR_OFFER) — фильтр accountType вернёт пустой список,
* а getAgreementsForProgram даст оферту независимо от типа;
* — порт не делает coop-scoping (capital не привязан к
* конкретному coopname в текущей модели), поэтому coopname в
* getAgreementsForAccountType передаётся только для будущего
* coop-аддитивного расширения через CooperativeConfigService.
*/
export interface AgreementQueryPort {
/**
* Соглашения, требуемые при регистрации пайщика заданного типа
* аккаунта. Если задан programKey — добавляются оферты программы.
*/
getAgreementsForAccountType(
accountType: AccountType,
coopname?: string,
programKey?: string
): IAgreementConfigItem[];
/**
* Оферта по строковому идентификатору, либо null если не найдена.
*/
getAgreementById(id: string): IAgreementConfigItem | null;
/**
* Все оферты, привязанные к программе через её agreement_ids.
*/
getAgreementsForProgram(programKey: string): IAgreementConfigItem[];
/**
* Программы, применимые к типу аккаунта в заданном кооперативе.
*/
getAvailablePrograms(coopname: string, accountType: AccountType): IRegistrationProgram[];
}
/** DI-токен для AgreementQueryPort при использовании через @Inject(). */
export const AGREEMENT_QUERY_PORT = Symbol('AgreementQueryPort');
@@ -0,0 +1,48 @@
import type { AgreementRegistrationSpec } from '../dto/agreement-registration-spec.dto';
import type { ProgramRegistrationSpec } from '../dto/program-registration-spec.dto';
/**
* Доменный порт, через который расширения регистрируют свои оферты
* и программы участия в платформе на этапе lifecycle initialize(config).
*
* Архитектурное назначение — раздел 4 req 44 проекта 13:
* ядро НЕ знает о конкретных расширениях, расширения сами наполняют
* реестр в рантайме. После EXTENSION_APP_TERMINATE_EVENT записи
* соответствующего расширения автоматически вычищаются реестром.
*
* Идемпотентность: повторный register с тем же id (или key) от того
* же extension_name перезаписывает запись, не дублируя её.
*/
export interface AgreementRegistrationPort {
/**
* Зарегистрировать (или обновить) оферту расширения.
* После вызова оферта становится видна сервисам построения UI/документов
* и через AgreementConfigurationService.getAgreementsForAccountType.
*/
registerAgreement(spec: AgreementRegistrationSpec): void;
/**
* Снять регистрацию оферты по id и extension_name.
* Используется редко: основной tear-down делает реестр сам
* по EXTENSION_APP_TERMINATE_EVENT.
*/
unregisterAgreement(id: string, extension_name: string): void;
/**
* Зарегистрировать (или обновить) программу участия расширения.
* Программа становится видна в SignUp/SelectProgram через
* AgreementConfigurationService.getAvailablePrograms.
*/
registerProgram(spec: ProgramRegistrationSpec): void;
/**
* Снять регистрацию программы по key и extension_name.
*/
unregisterProgram(key: string, extension_name: string): void;
}
/**
* DI-токен для AgreementRegistrationPort.
* Расширения инжектируют его как @Inject(AGREEMENT_REGISTRATION_PORT).
*/
export const AGREEMENT_REGISTRATION_PORT = Symbol('AgreementRegistrationPort');
@@ -0,0 +1,3 @@
export * from './agreement-registration.port';
export * from './agreement-query.port';
export * from './candidate-data.port';
@@ -1,7 +1,10 @@
import { Global, Module, forwardRef } from '@nestjs/common';
import { AgreementConfigurationService, AGREEMENT_CONFIGURATION_SERVICE } from './services/agreement-configuration.service';
import { AgreementRegistryService, AGREEMENT_REGISTRY_SERVICE } from './services/agreement-registry.service';
import { RegistrationDocumentsService, REGISTRATION_DOCUMENTS_SERVICE } from './services/registration-documents.service';
import { CooperativeConfigService } from './services/cooperative-config.service';
import { AGREEMENT_REGISTRATION_PORT } from './ports/agreement-registration.port';
import { AGREEMENT_QUERY_PORT } from './ports/agreement-query.port';
import { DocumentModule } from '~/application/document/document.module';
import { ExtensionDomainModule } from '~/domain/extension/extension-domain.module';
@@ -22,6 +25,19 @@ import { ExtensionDomainModule } from '~/domain/extension/extension-domain.modul
provide: AGREEMENT_CONFIGURATION_SERVICE,
useExisting: AgreementConfigurationService,
},
AgreementRegistryService,
{
provide: AGREEMENT_REGISTRY_SERVICE,
useExisting: AgreementRegistryService,
},
{
provide: AGREEMENT_REGISTRATION_PORT,
useExisting: AgreementRegistryService,
},
{
provide: AGREEMENT_QUERY_PORT,
useExisting: AgreementConfigurationService,
},
RegistrationDocumentsService,
{
provide: REGISTRATION_DOCUMENTS_SERVICE,
@@ -31,6 +47,10 @@ import { ExtensionDomainModule } from '~/domain/extension/extension-domain.modul
exports: [
AgreementConfigurationService,
AGREEMENT_CONFIGURATION_SERVICE,
AgreementRegistryService,
AGREEMENT_REGISTRY_SERVICE,
AGREEMENT_REGISTRATION_PORT,
AGREEMENT_QUERY_PORT,
RegistrationDocumentsService,
REGISTRATION_DOCUMENTS_SERVICE,
],
@@ -1,65 +1,108 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { REGISTRATION_AGREEMENTS_CONFIG } from '../config/registration-agreements.config';
import { REGISTRATION_PROGRAMS_CONFIG } from '../config/registration-programs.config';
import type {
IAgreementConfigItem,
ICooperativeRegistrationPrograms,
IRegistrationProgram,
} from '../config/agreement-config.interface';
import { AccountType } from '~/application/account/enum/account-type.enum';
import { AgreementId, ProgramKey } from '../enum';
import { AgreementRegistryService, AGREEMENT_REGISTRY_SERVICE } from './agreement-registry.service';
import type { AgreementRegistrationSpec } from '../dto/agreement-registration-spec.dto';
import type { ProgramRegistrationSpec } from '../dto/program-registration-spec.dto';
import { CooperativeConfigService } from './cooperative-config.service';
export const AGREEMENT_CONFIGURATION_SERVICE = Symbol('AgreementConfigurationService');
/**
* Сервис для работы с конфигурацией соглашений при регистрации
* Сервис для работы с конфигурацией соглашений при регистрации.
*
* После Эпика 1.3 раздел C28-10 источник правды двухуровневый:
* 1) платформенные оферты (signature/wallet/user/privacy) —
* из REGISTRATION_AGREEMENTS_CONFIG;
* 2) extension-оферты и программы — из AgreementRegistryService,
* наполняемого расширениями через AgreementRegistrationPort
* на этапе initialize(config).
*
* Этот сервис сливает оба источника в форму IAgreementConfigItem /
* IRegistrationProgram, которую ожидают вышестоящие consumers
* (Resolver, SignUp, participant.interactor).
*/
@Injectable()
export class AgreementConfigurationService {
constructor(private readonly cooperativeConfigService: CooperativeConfigService) {}
constructor(
private readonly cooperativeConfigService: CooperativeConfigService,
@Inject(AGREEMENT_REGISTRY_SERVICE)
private readonly agreementRegistry: AgreementRegistryService
) {}
/**
* Получить все соглашения для указанного типа аккаунта
* @param accountType - тип аккаунта (individual, organization, entrepreneur)
* @param coopname - название кооператива (для временного исключения blagorost_offer)
* @param programKey - ключ выбранной программы регистрации (опционально)
* @returns отсортированный по order массив конфигураций соглашений
* Адаптер spec → config item. Поля is_blockchain_agreement / link_to_statement
* для extension-оферт всегда true по контракту (план C28-10 раздел 1.1).
*/
private specToConfigItem(spec: AgreementRegistrationSpec): IAgreementConfigItem {
return {
id: spec.id,
registry_id: spec.registry_id,
agreement_type: spec.agreement_type,
title: spec.title,
checkbox_text: spec.checkbox_text,
link_text: spec.link_text,
is_blockchain_agreement: true,
link_to_statement: true,
applicable_account_types: spec.applicable_account_types,
order: spec.order,
};
}
private programSpecToConfig(spec: ProgramRegistrationSpec): IRegistrationProgram {
return {
key: spec.key,
title: spec.title,
description: spec.description,
image_url: spec.image_url,
requirements: spec.requirements,
applicable_account_types: spec.applicable_account_types,
agreement_ids: spec.agreement_ids,
order: spec.order,
};
}
/**
* Получить соглашения по умолчанию для кооператива (без учета программ)
* Соглашения по умолчанию для кооператива: платформенные базовые +
* extension-оферты, применимые к типу аккаунта (без программы).
*/
private getDefaultAgreementsForCooperative(
accountType: AccountType,
coopname?: string
): IAgreementConfigItem[] {
const coopnameValue = coopname || '';
const excludedAgreements = this.cooperativeConfigService.getExcludedFromBaseAgreements(coopnameValue);
// Получить базовые соглашения, исключая те, которые управляются отдельно
const baseAgreements = REGISTRATION_AGREEMENTS_CONFIG.agreements
.filter((a) => a.applicable_account_types.includes(accountType))
.filter((a) => !excludedAgreements.includes(a.id));
const baseAgreements = REGISTRATION_AGREEMENTS_CONFIG.agreements.filter((a) =>
a.applicable_account_types.includes(accountType)
);
const extensionAgreements = this.agreementRegistry
.listAgreementsForAccountType(accountType)
.map((spec) => this.specToConfigItem(spec));
// Добавить дополнительные соглашения по умолчанию для кооператива
const additionalAgreements = this.cooperativeConfigService
.getDefaultAdditionalAgreements(coopnameValue, accountType)
.map(id => this.getAgreementById(id))
.map((id) => this.getAgreementById(id))
.filter((a): a is IAgreementConfigItem => a !== null);
return [...baseAgreements, ...additionalAgreements].sort((a, b) => a.order - b.order);
return [...baseAgreements, ...extensionAgreements, ...additionalAgreements].sort(
(a, b) => a.order - b.order
);
}
getAgreementsForAccountType(
accountType: AccountType,
coopname?: string,
programKey?: ProgramKey
programKey?: string
): IAgreementConfigItem[] {
// Получить соглашения по умолчанию для кооператива
const defaultAgreements = this.getDefaultAgreementsForCooperative(accountType, coopname);
// Если указана программа - добавляем её соглашения
if (programKey && coopname) {
const programAgreements = this.getAgreementsForProgram(programKey, coopname);
if (programKey) {
const programAgreements = this.getAgreementsForProgram(programKey);
return [...defaultAgreements, ...programAgreements].sort((a, b) => a.order - b.order);
}
@@ -67,107 +110,90 @@ export class AgreementConfigurationService {
}
/**
* Получить соглашения, которые нужно отправить в блокчейн через sendAgreement
* @param accountType - тип аккаунта
* @param coopname - название кооператива (для временного исключения blagorost_offer)
* @param programKey - ключ выбранной программы регистрации
* @returns массив конфигураций blockchain agreements
* Соглашения, которые нужно отправить в блокчейн через sendAgreement
*/
getBlockchainAgreements(accountType: AccountType, coopname?: string, programKey?: ProgramKey): IAgreementConfigItem[] {
return this.getAgreementsForAccountType(accountType, coopname, programKey).filter((agreement) => agreement.is_blockchain_agreement);
getBlockchainAgreements(
accountType: AccountType,
coopname?: string,
programKey?: string
): IAgreementConfigItem[] {
return this.getAgreementsForAccountType(accountType, coopname, programKey).filter(
(agreement) => agreement.is_blockchain_agreement
);
}
/**
* Получить соглашения, которые нужно линковать в заявление на вступление
* @param accountType - тип аккаунта
* @param coopname - название кооператива (для временного исключения blagorost_offer)
* @param programKey - ключ выбранной программы регистрации
* @returns массив конфигураций соглашений для линковки
* Соглашения, которые нужно линковать в заявление на вступление
*/
getLinkedAgreements(accountType: AccountType, coopname?: string, programKey?: ProgramKey): IAgreementConfigItem[] {
return this.getAgreementsForAccountType(accountType, coopname, programKey).filter((agreement) => agreement.link_to_statement);
getLinkedAgreements(
accountType: AccountType,
coopname?: string,
programKey?: string
): IAgreementConfigItem[] {
return this.getAgreementsForAccountType(accountType, coopname, programKey).filter(
(agreement) => agreement.link_to_statement
);
}
/**
* Получить конфигурацию соглашения по идентификатору
* @param id - идентификатор соглашения (wallet_agreement, signature_agreement, etc.)
* @returns конфигурация соглашения или null
* Конфигурация соглашения по идентификатору (платформенный или
* extension-зарегистрированный).
*/
getAgreementById(id: AgreementId): IAgreementConfigItem | null {
return REGISTRATION_AGREEMENTS_CONFIG.agreements.find((agreement) => agreement.id === id) || null;
getAgreementById(id: string): IAgreementConfigItem | null {
const baseMatch = REGISTRATION_AGREEMENTS_CONFIG.agreements.find(
(agreement) => agreement.id === id
);
if (baseMatch) return baseMatch;
const spec = this.agreementRegistry.getAgreement(id);
return spec ? this.specToConfigItem(spec) : null;
}
/**
* Получить все доступные соглашения (без фильтрации по типу аккаунта)
* @returns все конфигурации соглашений
* Все доступные соглашения (без фильтрации по типу аккаунта).
*/
getAllAgreements(): IAgreementConfigItem[] {
return REGISTRATION_AGREEMENTS_CONFIG.agreements.sort((a, b) => a.order - b.order);
const base = REGISTRATION_AGREEMENTS_CONFIG.agreements;
const ext = this.agreementRegistry.listAgreements().map((spec) => this.specToConfigItem(spec));
return [...base, ...ext].sort((a, b) => a.order - b.order);
}
/**
* Проверить, требуется ли соглашение для данного типа аккаунта
* @param agreementId - идентификатор соглашения
* @param accountType - тип аккаунта
* @param coopname - название кооператива (для временного исключения blagorost_offer)
* @returns true если соглашение требуется
*/
isAgreementRequired(agreementId: AgreementId, accountType: AccountType, coopname?: string, programKey?: ProgramKey): boolean {
// Проверить, входит ли соглашение в список требуемых для данного кооператива и типа аккаунта
isAgreementRequired(
agreementId: string,
accountType: AccountType,
coopname?: string,
programKey?: string
): boolean {
const requiredAgreements = this.getRequiredAgreementIds(accountType, coopname, programKey);
return requiredAgreements.includes(agreementId);
}
/**
* Получить список идентификаторов всех требуемых соглашений для типа аккаунта
* @param accountType - тип аккаунта
* @param coopname - название кооператива (для временного исключения blagorost_offer)
* @param programKey - ключ выбранной программы регистрации
* @returns массив идентификаторов
*/
getRequiredAgreementIds(accountType: AccountType, coopname?: string, programKey?: ProgramKey): string[] {
getRequiredAgreementIds(
accountType: AccountType,
coopname?: string,
programKey?: string
): string[] {
return this.getAgreementsForAccountType(accountType, coopname, programKey).map((a) => a.id);
}
/**
* Получить доступные программы регистрации для кооператива и типа аккаунта
* @param coopname - название кооператива
* @param accountType - тип аккаунта
* @returns массив доступных программ
* Доступные программы регистрации для кооператива и типа аккаунта.
* coopname в текущей модели не используется (registry даёт программы
* одной инсталляции), но сохранён в сигнатуре ради совместимости
* с GraphQL-резолвером и для будущего coop-scoping.
*/
getAvailablePrograms(coopname: string, accountType: AccountType): IRegistrationProgram[] {
const config = REGISTRATION_PROGRAMS_CONFIG.find((c) => c.coopname === coopname);
if (!config) return [];
return config.programs
.filter((p) => p.applicable_account_types.includes(accountType))
.sort((a, b) => a.order - b.order);
getAvailablePrograms(_coopname: string, accountType: AccountType): IRegistrationProgram[] {
return this.agreementRegistry
.listProgramsForAccountType(accountType)
.map((spec) => this.programSpecToConfig(spec));
}
/**
* Получить конфигурацию программ для кооператива
* @param coopname - название кооператива
* @returns конфигурация программ или null
* Соглашения, привязанные к программе через её agreement_ids.
*/
getCooperativeProgramsConfig(coopname: string): ICooperativeRegistrationPrograms | null {
return REGISTRATION_PROGRAMS_CONFIG.find((c) => c.coopname === coopname) || null;
}
/**
* Получить соглашения для программы
* @param programKey - ключ программы
* @param coopname - название кооператива
* @returns массив соглашений для программы
*/
getAgreementsForProgram(programKey: ProgramKey, coopname: string): IAgreementConfigItem[] {
const config = REGISTRATION_PROGRAMS_CONFIG.find((c) => c.coopname === coopname);
if (!config) return [];
const program = config.programs.find((p) => p.key === programKey);
if (!program) return [];
return program.agreement_ids
.map((id) => this.getAgreementById(id))
.filter((a): a is IAgreementConfigItem => a !== null);
getAgreementsForProgram(programKey: string): IAgreementConfigItem[] {
return this.agreementRegistry
.listAgreementsForProgram(programKey)
.map((spec) => this.specToConfigItem(spec));
}
}
@@ -0,0 +1,176 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import type { AccountType } from '~/application/account/enum/account-type.enum';
import {
EXTENSION_APP_TERMINATE_EVENT,
type ExtensionAppTerminatePayload,
} from '~/domain/extension/extension-app-lifecycle.events';
import type { AgreementRegistrationSpec } from '../dto/agreement-registration-spec.dto';
import type { ProgramRegistrationSpec } from '../dto/program-registration-spec.dto';
import type { AgreementRegistrationPort } from '../ports/agreement-registration.port';
/**
* In-memory реестр оферт и программ, наполняемый расширениями
* через AgreementRegistrationPort в lifecycle initialize(config).
*
* Tear-down автоматический: подписан на EXTENSION_APP_TERMINATE_EVENT —
* при остановке расширения удаляет все записи с extension_name = appName.
*
* Идемпотентность: повторный register от того же extension_name с тем же id/key
* перезаписывает spec. Конфликт id/key между разными extension_name
* приводит к ConflictException — это симптом неконсистентной конфигурации
* и должен быть замечен на этапе runApp.
*/
@Injectable()
export class AgreementRegistryService implements AgreementRegistrationPort {
private readonly agreementMap = new Map<string, AgreementRegistrationSpec>();
private readonly programMap = new Map<string, ProgramRegistrationSpec>();
constructor(private readonly logger: WinstonLoggerService) {
this.logger.setContext(AgreementRegistryService.name);
}
registerAgreement(spec: AgreementRegistrationSpec): void {
const existing = this.agreementMap.get(spec.id);
if (existing && existing.extension_name !== spec.extension_name) {
throw new ConflictException(
`Agreement id "${spec.id}" уже зарегистрирована расширением "${existing.extension_name}", ` +
`повторная регистрация из "${spec.extension_name}" отклонена`
);
}
this.agreementMap.set(spec.id, spec);
this.logger.debug(
`[REGISTRY] register agreement ${spec.id} (${spec.agreement_type}) от ${spec.extension_name}`
);
}
unregisterAgreement(id: string, extension_name: string): void {
const existing = this.agreementMap.get(id);
if (!existing) return;
if (existing.extension_name !== extension_name) {
this.logger.warn(
`[REGISTRY] попытка ${extension_name} снять чужую регистрацию ${id} (владелец ${existing.extension_name}) проигнорирована`
);
return;
}
this.agreementMap.delete(id);
this.logger.debug(`[REGISTRY] unregister agreement ${id} от ${extension_name}`);
}
registerProgram(spec: ProgramRegistrationSpec): void {
const existing = this.programMap.get(spec.key);
if (existing && existing.extension_name !== spec.extension_name) {
throw new ConflictException(
`Program key "${spec.key}" уже зарегистрирована расширением "${existing.extension_name}", ` +
`повторная регистрация из "${spec.extension_name}" отклонена`
);
}
this.programMap.set(spec.key, spec);
this.logger.debug(`[REGISTRY] register program ${spec.key} от ${spec.extension_name}`);
}
unregisterProgram(key: string, extension_name: string): void {
const existing = this.programMap.get(key);
if (!existing) return;
if (existing.extension_name !== extension_name) {
this.logger.warn(
`[REGISTRY] попытка ${extension_name} снять чужую регистрацию программы ${key} (владелец ${existing.extension_name}) проигнорирована`
);
return;
}
this.programMap.delete(key);
this.logger.debug(`[REGISTRY] unregister program ${key} от ${extension_name}`);
}
/**
* Реестр чистит свои записи при остановке расширения.
* Слушатель — обязательный hook tear-down порта (раздел 4.2 req 44).
*/
@OnEvent(EXTENSION_APP_TERMINATE_EVENT)
onExtensionTerminate(payload: ExtensionAppTerminatePayload): void {
const removedAgreements: string[] = [];
for (const [id, spec] of this.agreementMap.entries()) {
if (spec.extension_name === payload.appName) {
this.agreementMap.delete(id);
removedAgreements.push(id);
}
}
const removedPrograms: string[] = [];
for (const [key, spec] of this.programMap.entries()) {
if (spec.extension_name === payload.appName) {
this.programMap.delete(key);
removedPrograms.push(key);
}
}
if (removedAgreements.length || removedPrograms.length) {
this.logger.info(
`[REGISTRY] terminate ${payload.appName}: снято ${removedAgreements.length} оферт, ${removedPrograms.length} программ`
);
}
}
/**
* Получить spec оферты по id, либо null если не зарегистрирована.
*/
getAgreement(id: string): AgreementRegistrationSpec | null {
return this.agreementMap.get(id) ?? null;
}
/**
* Получить spec программы по key, либо null.
*/
getProgram(key: string): ProgramRegistrationSpec | null {
return this.programMap.get(key) ?? null;
}
/**
* Все зарегистрированные оферты, отсортированные по order.
*/
listAgreements(): AgreementRegistrationSpec[] {
return Array.from(this.agreementMap.values()).sort((a, b) => a.order - b.order);
}
/**
* Все зарегистрированные программы, отсортированные по order.
*/
listPrograms(): ProgramRegistrationSpec[] {
return Array.from(this.programMap.values()).sort((a, b) => a.order - b.order);
}
/**
* Оферты, применимые к типу аккаунта (фильтр по applicable_account_types).
* Если applicable_account_types пуст — оферта НЕ выпадает «общим списком»
* (исторический контракт GENERATOR_OFFER: подключается через программу).
*/
listAgreementsForAccountType(accountType: AccountType): AgreementRegistrationSpec[] {
return this.listAgreements().filter((spec) =>
spec.applicable_account_types.includes(accountType)
);
}
/**
* Программы, применимые к типу аккаунта.
*/
listProgramsForAccountType(accountType: AccountType): ProgramRegistrationSpec[] {
return this.listPrograms().filter((spec) =>
spec.applicable_account_types.includes(accountType)
);
}
/**
* Все оферты, привязанные к программе по её agreement_ids.
* Возвращает оферты в порядке указания в program.agreement_ids,
* пропуская незарегистрированные (защита от рассинхрона).
*/
listAgreementsForProgram(programKey: string): AgreementRegistrationSpec[] {
const program = this.getProgram(programKey);
if (!program) return [];
return program.agreement_ids
.map((id) => this.getAgreement(id))
.filter((spec): spec is AgreementRegistrationSpec => spec !== null);
}
}
/** DI-токен для AgreementRegistryService при использовании через @Inject(). */
export const AGREEMENT_REGISTRY_SERVICE = Symbol('AgreementRegistryService');
@@ -1,9 +1,13 @@
import { Injectable } from '@nestjs/common';
import { AgreementId } from '../enum';
import { AccountType } from '~/application/account/enum/account-type.enum';
/**
* Сервис для определения конфигурации кооперативов
* Сервис для определения конфигурации кооперативов.
*
* После Эпика 1.3 фильтрация оферт по кооперативу больше не делается —
* расширения сами решают, регистрировать ли свои оферты, поэтому метод
* getExcludedFromBaseAgreements удалён. Метод hasPrograms/`getCooperativesWithPrograms`
* остаётся для feature-флага в SignUp (стоит ли вообще опрашивать программы).
*/
@Injectable()
export class CooperativeConfigService {
@@ -22,21 +26,11 @@ export class CooperativeConfigService {
}
/**
* Получить дополнительные соглашения по умолчанию для кооператива
* Дополнительные платформенные соглашения, которые включаются по
* правилам кооператива (а не через расширения). Сейчас всегда пусто —
* хук оставлен для будущей кастомизации.
*/
getDefaultAdditionalAgreements(coopname: string, accountType: AccountType): AgreementId[] {
// if (coopname === 'voskhod' && accountType === AccountType.individual) {
// return [AgreementId.BLAGOROST_OFFER];
// }
getDefaultAdditionalAgreements(_coopname: string, _accountType: AccountType): string[] {
return [];
}
/**
* Получить список соглашений, которые нужно исключить из базовых для данного кооператива
*/
getExcludedFromBaseAgreements(coopname: string): AgreementId[] {
// Для всех кооперативов исключаем соглашения, которые добавляются через программы или дополнительные
return [AgreementId.BLAGOROST_OFFER, AgreementId.GENERATOR_OFFER];
}
}
@@ -1,2 +1,3 @@
export * from './agreement-configuration.service';
export * from './agreement-registry.service';
export * from './registration-documents.service';
@@ -90,7 +90,7 @@ export class RegistrationDocumentsService {
private async generateDocumentParameters(
coopname: string,
username: string,
program_key?: ProgramKey
program_key?: string
): Promise<void> {
// Проверяем наличие реализации порта
if (!this.udataDocumentParametersPort) {
@@ -2,4 +2,8 @@ import type { CreateOrganizationDataInputDomainInterface } from '~/domain/accoun
export interface InitInputDomainInterface {
organization_data: CreateOrganizationDataInputDomainInterface;
// Если true — инициализация инициирована провайдером (через server-secret),
// флаг init_by_server проставляется в true безусловно. Если undefined/false —
// сохраняется текущая логика: setInitByServer(true) только при первой init.
is_server_init?: boolean;
}
@@ -90,14 +90,16 @@ export class CommitOutputDTO extends BaseOutputDTO {
commit_hash!: string;
@Field(() => String, {
nullable: true,
description: 'Описание коммита',
})
description!: string;
description?: string;
@Field(() => String, {
nullable: true,
description: 'Метаданные коммита',
})
meta!: string;
meta?: string;
@Field(() => GraphQLJSON, {
nullable: true,
@@ -0,0 +1,69 @@
import type { OnboardingStepRegistrationPort } from '~/domain/onboarding/ports/onboarding-step-registration.port';
/**
* Регистрирует 5 шагов онбординга capital в платформенном реестре.
*
* Нюанс: legacy enum CapitalOnboardingStepEnum использует значение
* `blagorost_program`, при этом config-поле называется
* `onboarding_blagorost_provision_done`. Платформенный сервис строит
* имена флагов напрямую из step_key (`onboarding_<step_key>_done`),
* поэтому здесь step_key = `'blagorost_provision'` — совпадает с
* существующим config-полем. Старый capital resolver продолжает
* работать со своим enum (`blagorost_program`); новый платформенный —
* со своим step_key. Оба читают/пишут одни и те же config-флаги.
*
* Generic-листенер пропускает capital, всю обработку
* DecisionTrackedEvent для capital продолжает делать
* CapitalOnboardingEventsService.
*/
export function registerCapitalOnboardingSteps(
port: OnboardingStepRegistrationPort
): void {
port.unregisterStepsByExtension('capital');
port.registerStep({
extension_name: 'capital',
step_key: 'generator_program_template',
event_type: 'SOVIET_DECISION',
vars_field: 'generator_program_template',
generator: 'free_decision',
default_title: 'Утверждение шаблона программы «Генератор»',
order: 10,
});
port.registerStep({
extension_name: 'capital',
step_key: 'generation_contract_template',
event_type: 'SOVIET_DECISION',
vars_field: 'generation_contract_template',
generator: 'free_decision',
default_title: 'Утверждение шаблона договора «Генерация»',
order: 20,
});
port.registerStep({
extension_name: 'capital',
step_key: 'generator_offer_template',
event_type: 'SOVIET_DECISION',
vars_field: 'generator_offer_template',
generator: 'free_decision',
default_title: 'Утверждение шаблона публичной оферты «Генератор»',
order: 30,
});
port.registerStep({
extension_name: 'capital',
step_key: 'blagorost_provision',
event_type: 'SOVIET_DECISION',
vars_field: 'blagorost_provision',
generator: 'free_decision',
default_title: 'Утверждение положения «Благорост»',
order: 40,
});
port.registerStep({
extension_name: 'capital',
step_key: 'blagorost_offer_template',
event_type: 'SOVIET_DECISION',
vars_field: 'blagorost_offer_template',
generator: 'free_decision',
default_title: 'Утверждение шаблона публичной оферты «Благорост»',
order: 50,
});
}
@@ -0,0 +1,98 @@
import { Cooperative } from 'cooptypes';
import { AccountType } from '~/application/account/enum/account-type.enum';
import type { AgreementRegistrationPort } from '~/domain/registration/ports/agreement-registration.port';
import {
BLAGOROST_AGREEMENT_TYPE,
BLAGOROST_OFFER_AGREEMENT_ID,
CAPITAL_EXTENSION_NAME,
CAPITALIZATION_PROGRAM_KEY,
GENERATION_PROGRAM_KEY,
GENERATOR_AGREEMENT_TYPE,
GENERATOR_OFFER_AGREEMENT_ID,
} from '../../constants/capital-agreement-ids';
import type { IConfig } from '../../capital-extension.module';
/**
* Регистрация оферт и программ Capital в платформенном AgreementRegistry.
*
* Чистая функция, не имеющая зависимостей от плагина или nest-контейнера:
* принимает `port` и `config`, выполняет series of register-вызовов.
* Это упрощает unit-тестирование (не требует boot всей капитал-graph
* с @octokit/rest, GitHub-схемами, базой и т.д.).
*
* Логика — Эпик 1.2 плана C28-10:
* • если L1-онбординг ещё не завершён (любой из 5 _done = false) —
* port не вызывается, реестр остаётся пустым для capital;
* • при завершённом L1 — две оферты (generator/blagorost) и две
* программы (generation/capitalization);
* • идемпотентность гарантируется AgreementRegistryService.
*/
export function registerCapitalInAgreementRegistry(
port: AgreementRegistrationPort,
extensionConfig: IConfig
): boolean {
const onboardingDone =
extensionConfig.onboarding_generator_program_template_done &&
extensionConfig.onboarding_generation_contract_template_done &&
extensionConfig.onboarding_generator_offer_template_done &&
extensionConfig.onboarding_blagorost_provision_done &&
extensionConfig.onboarding_blagorost_offer_template_done;
if (!onboardingDone) {
return false;
}
port.registerAgreement({
id: GENERATOR_OFFER_AGREEMENT_ID,
registry_id: Cooperative.Registry.GeneratorOffer.registry_id,
agreement_type: GENERATOR_AGREEMENT_TYPE,
title: 'Оферта по целевой потребительской программе "Генератор"',
checkbox_text: 'Я прочитал и принимаю',
link_text: 'оферту по целевой потребительской программе "Генератор"',
applicable_account_types: [],
order: 6,
extension_name: CAPITAL_EXTENSION_NAME,
});
port.registerAgreement({
id: BLAGOROST_OFFER_AGREEMENT_ID,
registry_id: Cooperative.Registry.BlagorostOffer.registry_id,
agreement_type: BLAGOROST_AGREEMENT_TYPE,
title: 'Оферта по целевой потребительской программе "Благорост"',
checkbox_text: 'Я прочитал и принимаю',
link_text: 'оферту по целевой потребительской программе "Благорост"',
// applicable_account_types: пусто — оферта подтягивается ТОЛЬКО через
// программу CAPITALIZATION (agreement_ids ниже), не как дефолтная
// для individual. Иначе при выборе GENERATION бэк попытался бы
// сгенерить blagorost_offer как дефолтную, но generateDocumentParameters
// под GENERATION зовёт только generateGeneratorOfferParameters →
// Factory падает «Данные соглашения благороста не найдены в Udata».
applicable_account_types: [],
order: 5,
extension_name: CAPITAL_EXTENSION_NAME,
});
port.registerProgram({
key: GENERATION_PROGRAM_KEY,
title: 'Программа Генерация',
description:
'Участвовать в производстве Кооперативной Экономики через паевой взнос временем, имуществом или деньгами в конкретные проекты.',
applicable_account_types: [AccountType.individual, AccountType.entrepreneur],
agreement_ids: [GENERATOR_OFFER_AGREEMENT_ID],
order: 1,
extension_name: CAPITAL_EXTENSION_NAME,
});
port.registerProgram({
key: CAPITALIZATION_PROGRAM_KEY,
title: 'Программа Благорост',
description:
'Участвовать в производстве Кооперативной Экономики через паевой взнос имуществом или денег в систему. Минимальный паевой взнос 100 000 руб в течение 14 дней.',
applicable_account_types: [AccountType.individual, AccountType.entrepreneur],
agreement_ids: [BLAGOROST_OFFER_AGREEMENT_ID],
order: 2,
extension_name: CAPITAL_EXTENSION_NAME,
});
return true;
}
@@ -7,9 +7,11 @@ import { RolesGuard } from '~/application/auth/guards/roles.guard';
import { UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { GenerationToMainWalletConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-to-main-wallet-convert-statement-document.dto';
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { GenerateDocumentInputDTO } from '~/application/document/dto/generate-document-input.dto';
@@ -57,60 +59,24 @@ export class DistributionManagementResolver {
// ============ ГЕНЕРАЦИЯ ДОКУМЕНТОВ ============
/**
* Мутация для генерации заявления о конвертации из генерации в основной кошелек
* Мутация для генерации заявления о конвертации целевого паевого взноса
* (универсальный шаблон: в Цифровой Кошелёк и/или в программу «Благорост»)
*/
@Mutation(() => GeneratedDocumentDTO, {
name: 'capitalGenerateGenerationToMainWalletConvertStatement',
description: 'Сгенерировать заявление о конвертации из генерации в основной кошелек',
name: 'capitalGenerateGenerationConvertStatement',
description: 'Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async generateGenerationToMainWalletConvertStatement(
@Args('data', { type: () => GenerationToMainWalletConvertStatementGenerateDocumentInputDTO })
data: GenerationToMainWalletConvertStatementGenerateDocumentInputDTO,
async generateGenerationConvertStatement(
@Args('data', { type: () => GenerationConvertStatementGenerateDocumentInputDTO })
data: GenerationConvertStatementGenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO
options: GenerateDocumentOptionsInputDTO,
@CurrentUser() currentUser: MonoAccountDomainInterface
): Promise<GeneratedDocumentDTO> {
return this.distributionManagementService.generateGenerationToMainWalletConvertStatement(data, options);
}
/**
* Мутация для генерации заявления о конвертации из генерации в проектный кошелек
*/
@Mutation(() => GeneratedDocumentDTO, {
name: 'capitalGenerateGenerationToProjectConvertStatement',
description: 'Сгенерировать заявление о конвертации из генерации в проектный кошелек',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async generateGenerationToProjectConvertStatement(
@Args('data', { type: () => GenerateDocumentInputDTO })
data: GenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return this.distributionManagementService.generateGenerationToProjectConvertStatement(data, options);
}
/**
* Мутация для генерации заявления о конвертации из генерации в благорост
*/
@Mutation(() => GeneratedDocumentDTO, {
name: 'capitalGenerateGenerationToCapitalizationConvertStatement',
description: 'Сгенерировать заявление о конвертации из генерации в благорост',
})
@Throttle({ default: { limit: 3, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async generateGenerationToCapitalizationConvertStatement(
@Args('data', { type: () => GenerateDocumentInputDTO })
data: GenerateDocumentInputDTO,
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
return this.distributionManagementService.generateGenerationToCapitalizationConvertStatement(data, options);
return this.distributionManagementService.generateGenerationConvertStatement(data, options, currentUser);
}
/**
@@ -5,10 +5,11 @@ import type { RefreshProgramInputDTO } from '../dto/distribution_management/refr
import type { TransactResult } from '@wharfkit/session';
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { GenerationToMainWalletConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-to-main-wallet-convert-statement-document.dto';
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
import { DocumentInteractor } from '~/application/document/interactors/document.interactor';
import { Cooperative } from 'cooptypes';
import type { GenerateDocumentInputDTO } from '~/application/document/dto/generate-document-input.dto';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
/**
* Сервис уровня приложения для управления распределением в CAPITAL
@@ -40,50 +41,19 @@ export class DistributionManagementService {
// ============ МЕТОДЫ ГЕНЕРАЦИИ ДОКУМЕНТОВ ============
/**
* Генерация заявления о конвертации из генерации в основной кошелек
* Генерация заявления о конвертации целевого паевого взноса
* (универсальный шаблон: в Цифровой Кошелёк и/или в программу «Благорост»)
*/
async generateGenerationToMainWalletConvertStatement(
data: GenerationToMainWalletConvertStatementGenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO
async generateGenerationConvertStatement(
data: GenerationConvertStatementGenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO,
currentUser: MonoAccountDomainInterface
): Promise<GeneratedDocumentDTO> {
const enrichedData = await this.distributionManagementInteractor.prepareGenerationConvertStatementData(data, currentUser);
const document = await this.documentInteractor.generateDocument({
data: {
...data,
registry_id: Cooperative.Registry.GenerationToMainWalletConvertStatement.registry_id,
},
options,
});
return document as GeneratedDocumentDTO;
}
/**
* Генерация заявления о конвертации из генерации в проектный кошелек
*/
async generateGenerationToProjectConvertStatement(
data: GenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
const document = await this.documentInteractor.generateDocument({
data: {
...data,
registry_id: Cooperative.Registry.GenerationToProjectConvertStatement.registry_id,
},
options,
});
return document as GeneratedDocumentDTO;
}
/**
* Генерация заявления о конвертации из генерации в благорост
*/
async generateGenerationToCapitalizationConvertStatement(
data: GenerateDocumentInputDTO,
options: GenerateDocumentOptionsInputDTO
): Promise<GeneratedDocumentDTO> {
const document = await this.documentInteractor.generateDocument({
data: {
...data,
registry_id: Cooperative.Registry.GenerationToCapitalizationConvertStatement.registry_id,
...enrichedData,
registry_id: Cooperative.Registry.GenerationConvertStatement.registry_id,
},
options,
});
@@ -125,7 +125,7 @@ export class MutationLogMapperService {
// Результаты и конвертация
capitalPushResult: LogEventType.RESULT_PUSHED,
capitalConvertSegment: LogEventType.SEGMENT_CONVERTED,
capitalGenerateGenerationToMainWalletConvertStatement: LogEventType.PROJECT_WITHDRAWAL,
capitalGenerateGenerationConvertStatement: LogEventType.PROJECT_WITHDRAWAL,
capitalGenerateCapitalizationToMainWalletConvertStatement: LogEventType.PROGRAM_WITHDRAWAL,
// Генерация - Stories, Issues, Cycles
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { DecisionTrackedEvent } from '~/domain/decision-tracking/events/decision-tracked.event';
import { ParticipantRegisteredEvent } from '~/domain/participant/interfaces/participant-registered-event.interface';
import { ProgramKey } from '~/domain/registration/enum';
@@ -16,6 +16,10 @@ import { ContributorDomainEntity } from '../../domain/entities/contributor.entit
import { ContributorStatus } from '../../domain/enums/contributor-status.enum';
import config from '~/config/config';
import { generateRandomHash } from '~/utils/generate-hash.util';
import {
ONBOARDING_COMPLETED_EVENT,
type OnboardingCompletedPayload,
} from '~/domain/onboarding/events/onboarding-completed.event';
@Injectable()
export class CapitalOnboardingEventsService {
@@ -23,11 +27,24 @@ export class CapitalOnboardingEventsService {
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository<IConfig>,
@Inject(CONTRIBUTOR_REPOSITORY) private readonly contributorRepository: ContributorRepository,
@Inject(ACCOUNT_DATA_PORT) private readonly accountDataPort: AccountDataPort,
private readonly logger: WinstonLoggerService
private readonly logger: WinstonLoggerService,
private readonly eventEmitter: EventEmitter2
) {
this.logger.setContext(CapitalOnboardingEventsService.name);
}
/**
* Все 5 шагов L1-онбординга capital, по чьим _done флагам определяется
* завершённость L1 в кооперативе.
*/
private static readonly L1_DONE_FLAGS: ReadonlyArray<keyof IConfig> = [
'onboarding_generator_program_template_done',
'onboarding_generation_contract_template_done',
'onboarding_generator_offer_template_done',
'onboarding_blagorost_provision_done',
'onboarding_blagorost_offer_template_done',
];
@OnEvent(DecisionTrackedEvent.eventName)
async handleDecisionTracked(event: DecisionTrackedEvent): Promise<void> {
const { result } = event;
@@ -54,6 +71,8 @@ export class CapitalOnboardingEventsService {
return;
}
const wasAlreadyDone = !!(plugin.config as any)[flagKey];
// Обновляем флаг завершения шага
const updatedConfig = {
...plugin.config,
@@ -63,6 +82,21 @@ export class CapitalOnboardingEventsService {
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
this.logger.info(`Флаг ${flagKey} установлен в true`);
// L1 auto-restart: если этим шагом флаг переходит из false → true И теперь
// все 5 _done флагов стоят, эмиттим ONBOARDING_COMPLETED_EVENT.
// ExtensionLifecycleDomainService подписан на него и сам сделает
// restartApp('capital'), чтобы initialize(config) увидел свежий config
// и зарегистрировал оферты/программы в платформенном реестре.
// Совет ничего не нажимает (раздел 7.1 плана C28-10, вариант (в)).
if (!wasAlreadyDone && this.isL1Complete(updatedConfig as IConfig)) {
this.logger.info(
'[ONBOARDING_COMPLETED] capital L1 завершён последним шагом, эмиттим событие для auto-restart'
);
this.eventEmitter.emit(ONBOARDING_COMPLETED_EVENT, {
extension_name: 'capital',
} satisfies OnboardingCompletedPayload);
}
} catch (error) {
const errorObj = error as Error;
this.logger.error(`Ошибка при обработке события завершения онбординга capital: ${errorObj.message}`, errorObj.stack);
@@ -187,6 +221,13 @@ export class CapitalOnboardingEventsService {
}
}
/**
* Проверка: все 5 шагов L1-онбординга capital завершены.
*/
private isL1Complete(cfg: IConfig): boolean {
return CapitalOnboardingEventsService.L1_DONE_FLAGS.every((flag) => !!(cfg as any)[flag]);
}
/**
* Маппинг шага онбординга в ключ флага конфигурации
*/
@@ -13,6 +13,7 @@ import config from '~/config/config';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import { DecisionTrackingPort, DECISION_TRACKING_PORT } from '~/domain/decision-tracking/ports/decision-tracking.port';
import { DecisionEventType } from '~/domain/decision-tracking/interfaces/tracking-rule-domain.interface';
import { computeOnboardingExpiresAt } from '~/domain/onboarding/constants/onboarding-ttl';
type OnboardingFlagKey =
| 'onboarding_generator_program_template_done'
@@ -100,8 +101,7 @@ export class CapitalOnboardingService {
if (!pluginConfig.onboarding_expire_at) {
const start = new Date(pluginConfig.onboarding_init_at);
const expire = new Date(start.getTime() + 30 * 24 * 60 * 60 * 1000);
pluginConfig.onboarding_expire_at = expire.toISOString();
pluginConfig.onboarding_expire_at = computeOnboardingExpiresAt(start);
needUpdate = true;
}
@@ -41,6 +41,14 @@ export class TimeTrackingService {
await this.timeTrackingInteractor.recalcDoneEstimatesForContributorProject(contributorHash, projectHash);
}
/**
* Откатить time-entries отклонённого коммита обратно в uncommitted и нормализовать
* раскладку estimate-долей для затронутых задач.
*/
async revertEntriesForDeclinedCommit(commitHash: string): Promise<void> {
await this.timeTrackingInteractor.revertEntriesForDeclinedCommit(commitHash);
}
/**
* Получить статистику времени для участника по проекту (DTO версия)
*/
@@ -3,6 +3,10 @@ import { CapitalBlockchainPort, CAPITAL_BLOCKCHAIN_PORT } from '../../domain/int
import type { TransactResult } from '@wharfkit/session';
import type { FundProgramDomainInput } from '../../domain/actions/fund-program-domain-input.interface';
import type { RefreshProgramDomainInput } from '../../domain/actions/refresh-program-domain-input.interface';
import { APPENDIX_REPOSITORY, AppendixRepository } from '../../domain/repositories/appendix.repository';
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import { Cooperative } from 'cooptypes';
/**
* Интерактор домена для распределения средств в CAPITAL контракте
@@ -12,7 +16,9 @@ import type { RefreshProgramDomainInput } from '../../domain/actions/refresh-pro
export class DistributionManagementInteractor {
constructor(
@Inject(CAPITAL_BLOCKCHAIN_PORT)
private readonly capitalBlockchainPort: CapitalBlockchainPort
private readonly capitalBlockchainPort: CapitalBlockchainPort,
@Inject(APPENDIX_REPOSITORY)
private readonly appendixRepository: AppendixRepository
) {}
/**
@@ -32,4 +38,31 @@ export class DistributionManagementInteractor {
return await this.capitalBlockchainPort.refreshProgram(data);
}
/**
* Подготавливает данные для генерации заявления о конвертации целевого паевого взноса.
* appendix_hash подтягивается по (username, project_hash) из подтверждённого приложения к проекту.
*/
async prepareGenerationConvertStatementData(
data: GenerationConvertStatementGenerateDocumentInputDTO,
currentUser: MonoAccountDomainInterface
): Promise<Cooperative.Registry.GenerationConvertStatement.Action> {
const projectHash = data.project_hash;
if (!projectHash) {
throw new Error('project_hash обязателен для генерации заявления о конвертации');
}
const userAppendix = await this.appendixRepository.findConfirmedByUsernameAndProjectHash(
currentUser.username,
projectHash
);
if (!userAppendix) {
throw new Error(`Не найдено подтверждённое приложение пользователя ${currentUser.username} для проекта ${projectHash}`);
}
return {
...data,
appendix_hash: userAppendix.appendix_hash,
} as Cooperative.Registry.GenerationConvertStatement.Action;
}
}
@@ -358,6 +358,11 @@ export class GenerationInteractor {
commit.status = CommitStatus.DECLINED;
await this.commitRepository.save(commit);
// Откатываем time-entries обратно в uncommitted и нормализуем раскладку долей
// по затронутым DONE-задачам. Иначе часы остаются в total_committed_hours и
// не возвращаются в доступный пул.
await this.timeTrackingService.revertEntriesForDeclinedCommit(data.commit_hash);
// Создаём данные для блокчейна
const blockchainData: CapitalContract.Actions.CommitDecline.ICommitDecline = {
coopname: data.coopname,
@@ -429,6 +434,10 @@ export class GenerationInteractor {
// Сохранить изменения
await this.commitRepository.save(commit);
// Откатить time-entries и нормализовать раскладку (идемпотентно: если decline
// уже был обработан через локальный путь, revert вернёт 0 затронутых).
await this.timeTrackingService.revertEntriesForDeclinedCommit(actionPayload.commit_hash);
this.logger.debug(`Коммит ${actionPayload.commit_hash} отклонен`);
} catch (error: any) {
this.logger.error(`Ошибка при обработке отклонения коммита: ${error?.message}`, error?.stack);
@@ -0,0 +1,536 @@
import { TimeTrackingInteractor } from './time-tracking.interactor';
import type { TimeEntryRepository } from '../../domain/repositories/time-entry.repository';
import type { ProjectRepository } from '../../domain/repositories/project.repository';
import type { ContributorRepository } from '../../domain/repositories/contributor.repository';
import type { IssueRepository } from '../../domain/repositories/issue.repository';
import { TimeEntryDomainEntity } from '../../domain/entities/time-entry.entity';
import { IssueStatus } from '../../domain/enums/issue-status.enum';
import { IssuePriority } from '../../domain/enums/issue-priority.enum';
import { IssueDomainEntity } from '../../domain/entities/issue.entity';
import type { ContributorDomainEntity } from '../../domain/entities/contributor.entity';
import { ContributorStatus } from '../../domain/enums/contributor-status.enum';
// Сценарии калиброваны по проду voskhod (issue CC7-1, estimate=15, 3 creators)
// чтобы локально воспроизводить найденные баги и предотвращать регрессию.
type Mocked<T> = { [K in keyof T]: jest.Mock };
function makeContributor(username: string, hash: string, coopname = 'voskhod'): ContributorDomainEntity {
return {
contributor_hash: hash,
username,
coopname,
display_name: username,
status: ContributorStatus.ACTIVE,
hours_per_day: 8,
} as unknown as ContributorDomainEntity;
}
function makeIssue(opts: {
issue_hash: string;
project_hash?: string;
coopname?: string;
estimate: number;
status: IssueStatus;
creators: string[];
id?: string;
}): IssueDomainEntity {
return {
id: opts.id ?? 'TST-1',
issue_hash: opts.issue_hash,
project_hash: opts.project_hash ?? 'project-hash-1',
coopname: opts.coopname ?? 'voskhod',
title: 'Test issue',
priority: IssuePriority.MEDIUM,
status: opts.status,
estimate: opts.estimate,
creators: opts.creators,
created_by: opts.creators[0] ?? 'ant',
sort_order: 0,
metadata: { labels: [], attachments: [] },
} as unknown as IssueDomainEntity;
}
function makeEntry(opts: {
contributor_hash: string;
issue_hash: string;
hours: number;
is_committed: boolean;
entry_type?: 'hourly' | 'estimate';
estimate_snapshot?: number;
commit_hash?: string;
_id?: string;
date?: string;
}): TimeEntryDomainEntity {
return new TimeEntryDomainEntity({
_id: opts._id ?? `e-${Math.random()}`,
contributor_hash: opts.contributor_hash,
issue_hash: opts.issue_hash,
project_hash: 'project-hash-1',
coopname: 'voskhod',
date: opts.date ?? '2026-05-15',
hours: opts.hours,
is_committed: opts.is_committed,
entry_type: opts.entry_type ?? 'estimate',
estimate_snapshot: opts.estimate_snapshot,
commit_hash: opts.commit_hash,
block_num: 0,
present: false,
status: 'active',
});
}
function buildInteractor() {
const timeEntryRepository: Mocked<TimeEntryRepository> = {
create: jest.fn().mockImplementation(async (e: TimeEntryDomainEntity) => e),
findByContributorAndDate: jest.fn().mockResolvedValue([]),
findUncommittedByContributor: jest.fn().mockResolvedValue([]),
findUncommittedByProjectAndContributor: jest.fn().mockResolvedValue([]),
update: jest.fn().mockImplementation(async (e: TimeEntryDomainEntity) => e),
updateMany: jest.fn().mockResolvedValue(undefined),
getTotalUncommittedHours: jest.fn().mockResolvedValue(0),
getContributorProjectStats: jest
.fn()
.mockResolvedValue({ total_committed_hours: 0, total_uncommitted_hours: 0 }),
commitTimeEntries: jest.fn().mockResolvedValue(undefined),
revertCommittedEntriesByCommitHash: jest.fn().mockResolvedValue(0),
findCommittedByCommitHash: jest.fn().mockResolvedValue([]),
delete: jest.fn().mockResolvedValue(undefined),
deleteUncommittedByIssueHash: jest.fn().mockResolvedValue(undefined),
findProjectsByContributor: jest.fn().mockResolvedValue([]),
findContributorsByProject: jest.fn().mockResolvedValue([]),
findByProjectWithPagination: jest
.fn()
.mockResolvedValue({ items: [], totalCount: 0, currentPage: 1, totalPages: 0 }),
getAggregatedTimeEntriesByIssues: jest.fn().mockResolvedValue([]),
getAggregatedTimeEntriesCount: jest.fn().mockResolvedValue(0),
findByIssueAndType: jest.fn().mockResolvedValue([]),
getTotalEstimateHoursByIssue: jest.fn().mockResolvedValue({ total: 0, estimate_snapshot: 0 }),
hasCommittedTimeByIssueHash: jest.fn().mockResolvedValue(false),
updateProjectHashByIssueHash: jest.fn().mockResolvedValue(undefined),
getFactByIssues: jest.fn().mockResolvedValue(new Map()),
};
const contributorRepository: Partial<Mocked<ContributorRepository>> = {
findByUsernameAndCoopname: jest.fn().mockResolvedValue(null),
findOne: jest.fn().mockResolvedValue(null),
findByStatusAndCoopname: jest.fn().mockResolvedValue([]),
};
const issueRepository: Partial<Mocked<IssueRepository>> = {
findByIssueHash: jest.fn().mockResolvedValue(null),
findByStatus: jest.fn().mockResolvedValue([]),
findByStatusAndCreators: jest.fn().mockResolvedValue([]),
findCompletedByProjectAndCreators: jest.fn().mockResolvedValue([]),
};
const projectRepository: Partial<Mocked<ProjectRepository>> = {
findAll: jest.fn().mockResolvedValue([]),
findByHash: jest.fn().mockResolvedValue(null),
};
const logger = {
setContext: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
log: jest.fn(),
verbose: jest.fn(),
} as any;
const interactor = new TimeTrackingInteractor(
timeEntryRepository as unknown as TimeEntryRepository,
projectRepository as unknown as ProjectRepository,
contributorRepository as unknown as ContributorRepository,
issueRepository as unknown as IssueRepository,
logger
);
return { interactor, timeEntryRepository, contributorRepository, issueRepository, projectRepository };
}
function expectEntryCreate(
mock: jest.Mock,
expected: Partial<{ contributor_hash: string; hours: number; entry_type: string; is_committed: boolean }>
): void {
const calls = mock.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
const match = calls.find((entry) => {
if (expected.contributor_hash && entry.contributor_hash !== expected.contributor_hash) return false;
if (expected.hours !== undefined && Math.abs(entry.hours - expected.hours) > 1e-6) return false;
if (expected.entry_type !== undefined && entry.entry_type !== expected.entry_type) return false;
if (expected.is_committed !== undefined && entry.is_committed !== expected.is_committed) return false;
return true;
});
if (!match) {
const summary = calls
.map((e) => `{contributor=${e.contributor_hash}, hours=${e.hours}, type=${e.entry_type}, committed=${e.is_committed}}`)
.join('\n ');
throw new Error(`No create() call matched ${JSON.stringify(expected)}.\nActual calls:\n ${summary}`);
}
}
describe('TimeTrackingInteractor.applyExplicitEstimateToTimeEntries', () => {
it('делит estimate поровну между creators при первой установке', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expect(timeEntryRepository.create).toHaveBeenCalledTimes(3);
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5, entry_type: 'estimate', is_committed: false });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5, entry_type: 'estimate', is_committed: false });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5, entry_type: 'estimate', is_committed: false });
});
it('при estimate=0 только удаляет незакоммиченные, новых не создаёт', async () => {
const { interactor, timeEntryRepository } = buildInteractor();
const issue = makeIssue({ issue_hash: 'i1', estimate: 0, status: IssueStatus.DONE, creators: ['ant'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
it('БАГ #1: не даёт «двойную долю» creator-у, который уже закоммитил свою часть', async () => {
// Прод-сценарий CC7-1: ant закоммитил полную долю (5 ч). При recalc/applyExplicit
// его доля = 5 5 = 0 (а не 10/3 = 3.33 как было в баговой версии). Остальные
// получают свои 5 ч, без «бонусной перераздачи остатка».
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
// ant не должен получить uncommitted estimate (он уже закоммитил полную долю)
const createCalls = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
const antEntries = createCalls.filter((e) => e.contributor_hash === 'ant-hash');
expect(antEntries).toHaveLength(0);
// Остальные двое получают по 5 ч каждый, а не 10/3
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
expect(timeEntryRepository.create).toHaveBeenCalledTimes(2);
});
it('частичный committed уменьшает долю только этого creator', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
// Смуров закоммитил 3 из 5 → его остаток 2, у остальных по 5
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 3, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 2 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
expect(timeEntryRepository.create).toHaveBeenCalledTimes(3);
});
it('overcommitted (закоммичено больше доли) даёт 0, не отрицательное значение', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 8, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
const antCreates = timeEntryRepository.create.mock.calls
.map((c) => c[0] as TimeEntryDomainEntity)
.filter((e) => e.contributor_hash === 'ant-hash');
expect(antCreates).toHaveLength(0);
});
it('пустой список creators — удаляет uncommitted, не создаёт новых', async () => {
const { interactor, timeEntryRepository } = buildInteractor();
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: [] });
await interactor.applyExplicitEstimateToTimeEntries(issue);
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
});
describe('TimeTrackingInteractor.recalcDoneEstimatesForContributorProject', () => {
it('no-op если раскладка уже совпадает с планом', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([issue]);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'dvl-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
it('лечит раскладку 10/3 (баговую) обратно к 5/5/5 когда есть закоммитившие', async () => {
// Воспроизведение прод-сценария CC7-1 после фикса: ant закоммитил 5 ч (committed),
// в БД остались баговые 3.333 uncommitted у всех. Recalc должен снести их и
// оставить ant=0, остальным по 5.
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([issue]);
timeEntryRepository.findByIssueAndType.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
makeEntry({ contributor_hash: 'dvl-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
]);
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5 });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
// ant не получает uncommitted (он уже закоммитил свою долю)
const antCreates = timeEntryRepository.create.mock.calls
.map((c) => c[0] as TimeEntryDomainEntity)
.filter((e) => e.contributor_hash === 'ant-hash');
expect(antCreates).toHaveLength(0);
});
it('пропускает задачи без estimate', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 0, status: IssueStatus.DONE, creators: ['ant'] }),
]);
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
});
describe('TimeTrackingInteractor.commitTime', () => {
it('БАГ #2: при partial split сохраняет entry_type=estimate и estimate_snapshot', async () => {
// Прод-сценарий: у Смурова 3.333 ч estimate uncommitted, коммит 3 ч → split.
// До фикса новая committed-запись создавалась с entry_type='hourly' (default).
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('smr', 'smr-hash'));
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] }),
]);
const entry = makeEntry({
contributor_hash: 'smr-hash',
issue_hash: 'i1',
hours: 5,
is_committed: false,
entry_type: 'estimate',
estimate_snapshot: 15,
_id: 'orig',
});
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([entry]);
await interactor.commitTime('smr-hash', 'project-hash-1', 3, 'commit-hash-1');
expect(timeEntryRepository.create).toHaveBeenCalledTimes(1);
const created = timeEntryRepository.create.mock.calls[0][0] as TimeEntryDomainEntity;
expect(created.is_committed).toBe(true);
expect(created.hours).toBe(3);
expect(created.entry_type).toBe('estimate');
expect(created.estimate_snapshot).toBe(15);
expect(created.commit_hash).toBe('commit-hash-1');
expect(timeEntryRepository.update).toHaveBeenCalledTimes(1);
const updated = timeEntryRepository.update.mock.calls[0][0] as TimeEntryDomainEntity;
expect(updated._id).toBe('orig');
expect(updated.hours).toBeCloseTo(2, 6);
});
it('full commit (entry.hours == requested) помечает оригинал, не создаёт split', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] }),
]);
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15, _id: 'orig' }),
]);
await interactor.commitTime('ant-hash', 'project-hash-1', 5, 'commit-hash-2');
expect(timeEntryRepository.create).not.toHaveBeenCalled();
expect(timeEntryRepository.commitTimeEntries).toHaveBeenCalledTimes(1);
});
it('коммит только по DONE задачам — отфильтровывает entries по активным задачам', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
// Только i1 в DONE, i2 в IN_PROGRESS
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant'] }),
]);
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate' }),
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i2', hours: 3, is_committed: false, entry_type: 'hourly' }),
]);
await interactor.commitTime('ant-hash', 'project-hash-1', 3, 'commit-hash-3');
// Должен коммитить из i1 (DONE), не из i2
const committedArgs = timeEntryRepository.commitTimeEntries.mock.calls[0]?.[0] as TimeEntryDomainEntity[] | undefined;
const splitCreate = timeEntryRepository.create.mock.calls[0]?.[0] as TimeEntryDomainEntity | undefined;
const touchedIssues = new Set<string>();
committedArgs?.forEach((e) => touchedIssues.add(e.issue_hash));
if (splitCreate) touchedIssues.add(splitCreate.issue_hash);
expect(touchedIssues.has('i2')).toBe(false);
});
});
describe('TimeTrackingInteractor.revertEntriesForDeclinedCommit', () => {
it('БАГ #3: вызывает revert + нормализует раскладку по затронутой DONE-задаче', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const reverted = makeEntry({
contributor_hash: 'ant-hash',
issue_hash: 'i1',
hours: 5,
is_committed: true,
entry_type: 'estimate',
estimate_snapshot: 15,
commit_hash: 'commit-bad',
});
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([reverted]);
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
issueRepository.findByIssueHash!.mockResolvedValue(
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] })
);
// После revert у ant'a 0 committed (revert убрал) — поэтому новая раскладка 5/5/5
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
await interactor.revertEntriesForDeclinedCommit('commit-bad');
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).toHaveBeenCalledWith('commit-bad');
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5, entry_type: 'estimate' });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5, entry_type: 'estimate' });
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5, entry_type: 'estimate' });
});
it('идемпотентно: повторный вызов на коммит без записей — no-op', async () => {
const { interactor, timeEntryRepository } = buildInteractor();
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([]);
await interactor.revertEntriesForDeclinedCommit('commit-already-reverted');
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).not.toHaveBeenCalled();
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
});
it('не пересчитывает задачи которые больше не DONE', async () => {
const { interactor, timeEntryRepository, issueRepository } = buildInteractor();
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 3, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15, commit_hash: 'c1' }),
]);
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
issueRepository.findByIssueHash!.mockResolvedValue(
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.IN_PROGRESS, creators: ['ant'] })
);
await interactor.revertEntriesForDeclinedCommit('c1');
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).toHaveBeenCalled();
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
expect(timeEntryRepository.create).not.toHaveBeenCalled();
});
});
describe('integration scenario: CC7-1 прод-инцидент', () => {
it('после декларации estimate=15 на 3 creators даёт по 5 ч каждому, total uncommitted=15', async () => {
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const issue = makeIssue({
issue_hash: 'cc7-1-hash',
estimate: 15,
status: IssueStatus.DONE,
creators: ['ant', 'zxfevlujlica', 'ipesgnlxmnwx'],
});
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
await interactor.applyExplicitEstimateToTimeEntries(issue);
const creates = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
expect(creates).toHaveLength(3);
const totalHours = creates.reduce((s, e) => s + e.hours, 0);
expect(totalHours).toBeCloseTo(15, 6);
creates.forEach((e) => {
expect(e.hours).toBeCloseTo(5, 6);
expect(e.entry_type).toBe('estimate');
expect(e.is_committed).toBe(false);
expect(e.estimate_snapshot).toBe(15);
});
});
it('сценарий full lifecycle: коммит → decline → revert восстанавливает доступное время', async () => {
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
makeContributor(u, `${u}-hash`)
);
const doneIssue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([doneIssue]);
issueRepository.findByIssueHash!.mockResolvedValue(doneIssue);
// 1. Стартовое состояние: 5/5/5 uncommitted
const initial = [
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15, _id: 'a1' }),
];
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue(initial);
// 2. ant коммитит свои 5 ч
await interactor.commitTime('ant-hash', 'project-hash-1', 5, 'commit-ant');
expect(timeEntryRepository.commitTimeEntries).toHaveBeenCalledTimes(1);
// 3. Симулируем что в БД теперь есть committed estimate 5 ч от ant'a
const committedEntry = makeEntry({
contributor_hash: 'ant-hash',
issue_hash: 'i1',
hours: 5,
is_committed: true,
entry_type: 'estimate',
estimate_snapshot: 15,
commit_hash: 'commit-ant',
});
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([committedEntry]);
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
// После revert у ant'а 0 committed estimate → новая раскладка 5/5/5
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
// 4. Decline → revert
timeEntryRepository.create.mockClear();
timeEntryRepository.deleteUncommittedByIssueHash.mockClear();
await interactor.revertEntriesForDeclinedCommit('commit-ant');
// 5. После revert: 5/5/5 uncommitted
const creates = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
const total = creates.reduce((s, e) => s + e.hours, 0);
expect(total).toBeCloseTo(15, 6);
expect(creates.every((e) => e.entry_type === 'estimate' && !e.is_committed)).toBe(true);
});
});
@@ -49,64 +49,116 @@ export class TimeTrackingInteractor {
}
/**
* Реакция на фактическое изменение оценки (вызывать только если сохранённое и новое значение
* различаются): снимаем все незакоммиченные билеты по задаче, затем при ненулевой оценке создаём
* новые записи estimate (поровну между исполнителями). При оценке 0 — только очистка.
* Реакция на фактическое изменение оценки: пересобирает незакоммиченные estimate-билеты
* задачи под текущий состав creators и новую оценку, учитывая личный committed-баланс
* каждого исполнителя. При оценке 0 — только очистка незакоммиченных.
*/
async applyExplicitEstimateToTimeEntries(issue: IssueDomainEntity): Promise<void> {
const newEstimate = issue.estimate ?? 0;
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
if (isNegligibleHours(newEstimate)) {
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
this.logger.debug(
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}), оценка снята или обнулена — незакоммиченные билеты по задаче удалены`
);
return;
}
await this.redistributeIssueEstimateEntries(issue, newEstimate, { force: true });
}
/**
* Пересобрать незакоммиченные estimate-билеты задачи: для каждого creator личная
* доля = estimate / N, минус его уже закоммиченные estimate-часы по этой же задаче.
* Закоммиченные записи не трогаются. «Остаток» одного creator не перераспределяется
* между другими — каждый учитывается изолированно.
*
* @param opts.force true — пересоздаёт всегда; false — пропускает no-op когда раскладка
* уже совпадает с планом.
* @returns true если что-то пересоздано/удалено, false если no-op
*/
private async redistributeIssueEstimateEntries(
issue: IssueDomainEntity,
estimate: number,
opts: { force: boolean }
): Promise<boolean> {
const creators = issue.creators || [];
if (creators.length === 0) {
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
this.logger.warn(
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}) без исполнителей — билеты очищены, начисление estimate пропущено`
`redistributeIssueEstimateEntries: задача ${issue.id} (${issue.issue_hash}) без исполнителей — билеты очищены`
);
return;
return true;
}
const hoursPerCreator = newEstimate / creators.length;
const date = new Date().toISOString().split('T')[0];
const sharePerCreator = estimate / creators.length;
const estimateEntries = await this.timeEntryRepository.findByIssueAndType(issue.issue_hash, 'estimate');
const committedByContributor = new Map<string, number>();
for (const entry of estimateEntries) {
if (!entry.is_committed) continue;
committedByContributor.set(
entry.contributor_hash,
(committedByContributor.get(entry.contributor_hash) || 0) + entry.hours
);
}
type Plan = { contributor_hash: string; uncommittedShare: number };
const plan: Plan[] = [];
for (const creatorUsername of creators) {
const contributor = await this.contributorRepository.findByUsernameAndCoopname(creatorUsername, issue.coopname);
if (!contributor) {
this.logger.warn(
`applyExplicitEstimateToTimeEntries: исполнитель ${creatorUsername} не найден в ${issue.coopname}, пропуск`
`redistributeIssueEstimateEntries: исполнитель ${creatorUsername} не найден в ${issue.coopname}, пропуск`
);
continue;
}
const myCommitted = committedByContributor.get(contributor.contributor_hash) || 0;
const uncommittedShare = Math.max(0, sharePerCreator - myCommitted);
plan.push({ contributor_hash: contributor.contributor_hash, uncommittedShare });
}
const estimateEntry = new TimeEntryDomainEntity({
_id: '',
contributor_hash: contributor.contributor_hash,
issue_hash: issue.issue_hash,
project_hash: issue.project_hash,
coopname: issue.coopname,
date,
hours: hoursPerCreator,
is_committed: false,
block_num: 0,
present: false,
status: 'active',
entry_type: 'estimate',
estimate_snapshot: newEstimate,
});
if (!opts.force) {
const currentUncommitted = estimateEntries.filter((e) => !e.is_committed);
const planNonZero = plan.filter((p) => p.uncommittedShare > HOURS_FLOAT_EPSILON);
const planByHash = new Map(planNonZero.map((p) => [p.contributor_hash, p.uncommittedShare]));
const isValid =
currentUncommitted.length === planNonZero.length &&
currentUncommitted.every((entry) => {
const expected = planByHash.get(entry.contributor_hash);
return expected !== undefined && hoursAlmostEqual(entry.hours, expected);
});
if (isValid) return false;
}
await this.timeEntryRepository.create(estimateEntry);
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
const date = new Date().toISOString().split('T')[0];
for (const item of plan) {
if (item.uncommittedShare <= HOURS_FLOAT_EPSILON) continue;
await this.timeEntryRepository.create(
new TimeEntryDomainEntity({
_id: '',
contributor_hash: item.contributor_hash,
issue_hash: issue.issue_hash,
project_hash: issue.project_hash,
coopname: issue.coopname,
date,
hours: item.uncommittedShare,
is_committed: false,
block_num: 0,
present: false,
status: 'active',
entry_type: 'estimate',
estimate_snapshot: estimate,
})
);
}
this.logger.debug(
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}), выставлена оценка ${newEstimate} ч — незакоммиченные билеты заменены`
`redistributeIssueEstimateEntries: задача ${issue.id} (${issue.issue_hash}), estimate=${estimate} ч, ` +
`share=${sharePerCreator} ч × ${creators.length} creators, незакоммиченные пересозданы (${plan.length} участников)`
);
return true;
}
/**
@@ -117,12 +169,38 @@ export class TimeTrackingInteractor {
await this.timeEntryRepository.deleteUncommittedByIssueHash(issueHash);
}
/**
* Откатить time-entries отклонённого коммита обратно в uncommitted и нормализовать
* раскладку estimate-долей для затронутых DONE-задач (если их состав creators
* актуален). Идемпотентно: вызов на коммит без time-entries — no-op.
*/
async revertEntriesForDeclinedCommit(commitHash: string): Promise<void> {
const reverted = await this.timeEntryRepository.findCommittedByCommitHash(commitHash);
if (reverted.length === 0) return;
await this.timeEntryRepository.revertCommittedEntriesByCommitHash(commitHash);
const issueHashes = [...new Set(reverted.map((e) => e.issue_hash))];
for (const issueHash of issueHashes) {
const issue = await this.issueRepository.findByIssueHash(issueHash);
if (!issue) continue;
if (issue.status !== IssueStatus.DONE) continue;
const estimate = issue.estimate ?? 0;
if (isNegligibleHours(estimate)) continue;
await this.redistributeIssueEstimateEntries(issue, estimate, { force: true });
}
this.logger.debug(
`revertEntriesForDeclinedCommit: коммит ${commitHash}, откачено ${reverted.length} записей, ` +
`пересчитано ${issueHashes.length} задач`
);
}
/**
* Лечебный пересчёт: для всех DONE-задач проекта, где участник сейчас в creators,
* сносит незакоммиченные estimate-билеты и распределяет оставшуюся часть estimate
* (estimate − уже закоммиченные часы по задаче) поровну между текущими creators.
* Идемпотентно. Вызывается, например, перед createCommit, чтобы вылечить расхождения,
* накопившиеся после смен creators/статусов, не прошедших через applyExplicitEstimate.
* пересобирает незакоммиченные estimate-билеты по личным долям creators
* (estimate / N − собственный committed). Идемпотентно. Вызывается перед createCommit
* и из миграций, чтобы вылечить расхождения после смен creators/статусов.
*/
async recalcDoneEstimatesForContributorProject(contributorHash: string, projectHash: string): Promise<void> {
const contributor = await this.contributorRepository.findOne({ contributor_hash: contributorHash });
@@ -135,76 +213,7 @@ export class TimeTrackingInteractor {
for (const issue of completedIssues) {
const estimate = issue.estimate ?? 0;
if (isNegligibleHours(estimate)) continue;
const creators = issue.creators || [];
if (creators.length === 0) continue;
const estimateEntries = await this.timeEntryRepository.findByIssueAndType(issue.issue_hash, 'estimate');
const committedTotal = estimateEntries
.filter((entry) => entry.is_committed)
.reduce((sum, entry) => sum + entry.hours, 0);
const uncommittedTotal = estimateEntries
.filter((entry) => !entry.is_committed)
.reduce((sum, entry) => sum + entry.hours, 0);
const remaining = estimate - committedTotal;
const expectedHoursPerCreator = remaining > HOURS_FLOAT_EPSILON ? remaining / creators.length : 0;
const currentCreatorHashes = new Set<string>();
for (const creatorUsername of creators) {
const creatorContributor = await this.contributorRepository.findByUsernameAndCoopname(
creatorUsername,
issue.coopname
);
if (creatorContributor) currentCreatorHashes.add(creatorContributor.contributor_hash);
}
const uncommittedEntries = estimateEntries.filter((entry) => !entry.is_committed);
const distributionLooksValid =
uncommittedEntries.length === currentCreatorHashes.size &&
uncommittedEntries.every(
(entry) =>
currentCreatorHashes.has(entry.contributor_hash) &&
hoursAlmostEqual(entry.hours, expectedHoursPerCreator)
) &&
hoursAlmostEqual(uncommittedTotal, remaining > HOURS_FLOAT_EPSILON ? remaining : 0);
if (distributionLooksValid) continue;
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
if (remaining <= HOURS_FLOAT_EPSILON) continue;
const date = new Date().toISOString().split('T')[0];
for (const creatorUsername of creators) {
const creatorContributor = await this.contributorRepository.findByUsernameAndCoopname(
creatorUsername,
issue.coopname
);
if (!creatorContributor) continue;
await this.timeEntryRepository.create(
new TimeEntryDomainEntity({
_id: '',
contributor_hash: creatorContributor.contributor_hash,
issue_hash: issue.issue_hash,
project_hash: issue.project_hash,
coopname: issue.coopname,
date,
hours: expectedHoursPerCreator,
is_committed: false,
block_num: 0,
present: false,
status: 'active',
entry_type: 'estimate',
estimate_snapshot: estimate,
})
);
}
this.logger.debug(
`recalcDoneEstimatesForContributorProject: задача ${issue.id} (${issue.issue_hash}) — пересчитаны estimate-билеты: ` +
`estimate=${estimate} ч, committed=${committedTotal} ч, distributed=${expectedHoursPerCreator} ч × ${creators.length}`
);
await this.redistributeIssueEstimateEntries(issue, estimate, { force: false });
}
}
@@ -801,7 +810,9 @@ export class TimeTrackingInteractor {
entriesToCommit.push(entry);
remainingHours -= entry.hours;
} else {
// Коммитим часть записи - создаём новую запись с оставшимся временем
// Коммитим часть записи - создаём новую запись с оставшимся временем.
// entry_type и estimate_snapshot копируем из оригинала — иначе recalc estimate
// не увидит закоммиченную долю (он фильтрует только entry_type='estimate').
const committedEntry = new TimeEntryDomainEntity({
_id: '',
contributor_hash: entry.contributor_hash,
@@ -812,6 +823,8 @@ export class TimeTrackingInteractor {
hours: remainingHours,
commit_hash: commitHash,
is_committed: true,
entry_type: entry.entry_type,
estimate_snapshot: entry.estimate_snapshot,
block_num: entry.block_num,
present: entry.present,
status: entry.status,
@@ -267,6 +267,16 @@ import { UdataDocumentParametersService, UDATA_DOCUMENT_PARAMETERS_SERVICE } fro
import { UdataDocumentParametersAdapter } from './infrastructure/adapters/udata-document-parameters.adapter';
import { CapitalInterProjectCapitalClearanceAdapter } from './infrastructure/inter/capital-inter-project-capital-clearance.adapter';
import { UDATA_DOCUMENT_PARAMETERS_PORT } from '~/domain/common/ports/udata-document-parameters.port';
import {
AGREEMENT_REGISTRATION_PORT,
type AgreementRegistrationPort,
} from '~/domain/registration/ports/agreement-registration.port';
import { registerCapitalInAgreementRegistry } from './application/registration/register-capital-in-agreement-registry';
import {
ONBOARDING_STEP_REGISTRATION_PORT,
type OnboardingStepRegistrationPort,
} from '~/domain/onboarding/ports/onboarding-step-registration.port';
import { registerCapitalOnboardingSteps } from './application/onboarding/register-capital-onboarding-steps';
// Репозитории
import { ProjectTypeormRepository } from './infrastructure/repositories/project.typeorm-repository';
@@ -459,12 +469,16 @@ export class CapitalPlugin extends BaseExtModule {
private readonly programShareRegistrationScheduler: ProgramShareRegistrationSchedulerService,
private readonly githubService: GitHubService,
private readonly gitService: GitService,
private readonly capitalDevelopmentRepositoryGitSync: CapitalDevelopmentRepositoryGitSyncService
private readonly capitalDevelopmentRepositoryGitSync: CapitalDevelopmentRepositoryGitSyncService,
@Inject(AGREEMENT_REGISTRATION_PORT) private readonly agreementRegistrationPort: AgreementRegistrationPort,
@Inject(ONBOARDING_STEP_REGISTRATION_PORT)
private readonly onboardingStepRegistration: OnboardingStepRegistrationPort
) {
super();
this.logger.setContext(CapitalPlugin.name);
}
name = 'capital';
plugin!: ExtensionDomainEntity<IConfig>; // ExtensionDomainEntity<IConfig>;
@@ -610,6 +624,37 @@ export class CapitalPlugin extends BaseExtModule {
const stack = error instanceof Error ? error.stack : undefined
this.logger.error(`Не удалось настроить планировщик regshare: ${message}`, stack)
}
// Регистрация шагов онбординга capital в платформенном реестре
try {
registerCapitalOnboardingSteps(this.onboardingStepRegistration);
this.logger.log('[CAPITAL.ONBOARDING] зарегистрировано 5 шагов онбординга capital');
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
this.logger.error(
`Не удалось зарегистрировать шаги онбординга capital: ${message}`,
stack
);
}
// L2: регистрация оферт и программ capital в платформенном реестре.
// Если L1 ещё не завершён — реестр остаётся пустым, SignUp не предлагает
// программы capital (раздел 4.1 req 44 проекта 13).
try {
const registered = registerCapitalInAgreementRegistry(this.agreementRegistrationPort, extensionConfig as IConfig);
if (registered) {
this.logger.log('[CAPITAL.REGISTRY] зарегистрировано 2 оферты + 2 программы capital');
} else {
this.logger.log(
'[CAPITAL.REGISTRY] L1 онбординг ещё не завершён — оферты и программы не регистрируются'
);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Не удалось зарегистрировать capital в AgreementRegistry: ${message}`, stack);
}
}
}
@@ -0,0 +1,32 @@
/**
* Идентификаторы оферт и программ расширения Capital.
*
* Это локальный source-of-truth для строковых значений, которые
* расширение регистрирует в платформенном AgreementRegistry
* (см. register-capital-in-agreement-registry.ts).
*
* До Эпика 1.3 эти же значения дублируются в ядре в виде
* `AgreementId.{BLAGOROST_OFFER,GENERATOR_OFFER}` и
* `AgreementType.{CAPITAL,GENERATOR}`. После Эпика 1.3 ядерные
* enum-записи будут удалены, а Capital единственный их носитель.
*
* `*_PROGRAM_KEY` ключи программ онбординга, приходящие в
* `AgreementRegistry.registerProgram({ key })`. Значения совпадают
* со строками ядерного `ProgramKey` enum (GENERATION/CAPITALIZATION),
* чтобы consumers, сравнивающие `data.program_key` через
* `ProgramKey.X`, работали без преобразований.
*/
export const CAPITAL_EXTENSION_NAME = 'capital';
export const GENERATOR_OFFER_AGREEMENT_ID = 'generator_offer';
export const BLAGOROST_OFFER_AGREEMENT_ID = 'blagorost_offer';
export const GENERATOR_AGREEMENT_TYPE = 'generator';
// On-chain имя оферты Благорост в `soviet::coagreements` — 'capital' (program_id=4).
// Контроллер обязан слать sndagreement/signagree с этим значением, иначе
// `get_coagreement_or_fail` падает с «Соглашение указанного типа не найдено».
export const BLAGOROST_AGREEMENT_TYPE = 'capital';
export const GENERATION_PROGRAM_KEY = 'GENERATION';
export const CAPITALIZATION_PROGRAM_KEY = 'CAPITALIZATION';
@@ -59,6 +59,21 @@ export interface TimeEntryRepository {
*/
commitTimeEntries(entries: TimeEntryDomainEntity[], commitHash: string): Promise<void>;
/**
* Найти закоммиченные time-entries по commit_hash. Используется при decline,
* чтобы понять какие задачи затронуты до отката.
*/
findCommittedByCommitHash(commitHash: string): Promise<TimeEntryDomainEntity[]>;
/**
* Откатить закоммиченные time-entries по commit_hash обратно в uncommitted
* (is_committed=false, commit_hash=NULL). Используется при decline коммита, чтобы
* часы вернулись в доступный пул. Идемпотентно.
*
* @returns количество затронутых записей
*/
revertCommittedEntriesByCommitHash(commitHash: string): Promise<number>;
/**
* Удалить запись времени
*/
@@ -121,6 +121,24 @@ export class TimeEntryTypeormRepository implements TimeEntryRepository {
await this.repository.update({ _id: In(ids) }, { commit_hash: commitHash, is_committed: true, _updated_at: new Date() });
}
async findCommittedByCommitHash(commitHash: string): Promise<TimeEntryDomainEntity[]> {
const entities = await this.repository.find({
where: { commit_hash: commitHash, is_committed: true },
});
return entities.map((entity) => this.toDomain(entity));
}
async revertCommittedEntriesByCommitHash(commitHash: string): Promise<number> {
const result = await this.repository
.createQueryBuilder()
.update(TimeEntryEntity)
.set({ is_committed: false, commit_hash: null as unknown as undefined, _updated_at: new Date() })
.where('commit_hash = :commitHash', { commitHash })
.andWhere('is_committed = true')
.execute();
return result.affected ?? 0;
}
async delete(id: string): Promise<void> {
await this.repository.delete(id);
}
@@ -0,0 +1,86 @@
import type { OnboardingStepRegistrationPort } from '~/domain/onboarding/ports/onboarding-step-registration.port';
/**
* Регистрирует 7 шагов онбординга chairman в платформенном реестре.
*
* Step_key'и совпадают с именами enum'а ChairmanOnboardingAgendaStepEnum и
* с подстрокой существующих config-полей `onboarding_<step_key>_done/hash`,
* чтобы legacy resolver (Chairman OnboardingResolver/Service) и
* платформенный resolver (ExtensionOnboardingResolver) видели одно и то же
* состояние.
*
* Generic-листенер ExtensionOnboardingEventsService пропускает chairman
* (см. LEGACY_EXTENSIONS_WITH_OWN_LISTENER), всю обработку
* DecisionTrackedEvent для chairman продолжает делать
* ChairmanOnboardingEventsService. Регистрация шагов здесь нужна,
* чтобы платформенный QUERY-резолвер возвращал состояние для chairman.
*/
export function registerChairmanOnboardingSteps(
port: OnboardingStepRegistrationPort
): void {
port.unregisterStepsByExtension('chairman');
port.registerStep({
extension_name: 'chairman',
step_key: 'wallet_agreement',
event_type: 'SOVIET_DECISION',
vars_field: 'wallet_agreement',
generator: 'free_decision',
default_title: 'Утверждение кошелькового соглашения',
order: 10,
});
port.registerStep({
extension_name: 'chairman',
step_key: 'signature_agreement',
event_type: 'SOVIET_DECISION',
vars_field: 'signature_agreement',
generator: 'free_decision',
default_title: 'Утверждение соглашения о простой электронной подписи',
order: 20,
});
port.registerStep({
extension_name: 'chairman',
step_key: 'privacy_agreement',
event_type: 'SOVIET_DECISION',
vars_field: 'privacy_agreement',
generator: 'free_decision',
default_title: 'Утверждение политики конфиденциальности',
order: 30,
});
port.registerStep({
extension_name: 'chairman',
step_key: 'user_agreement',
event_type: 'SOVIET_DECISION',
vars_field: 'user_agreement',
generator: 'free_decision',
default_title: 'Утверждение пользовательского соглашения',
order: 40,
});
port.registerStep({
extension_name: 'chairman',
step_key: 'participant_application',
event_type: 'SOVIET_DECISION',
vars_field: 'participant_application',
generator: 'free_decision',
default_title: 'Утверждение шаблонов заявлений участника',
order: 50,
});
port.registerStep({
extension_name: 'chairman',
step_key: 'voskhod_membership',
event_type: 'SOVIET_DECISION',
vars_field: 'voskhod_membership',
generator: 'free_decision',
default_title: 'Решение о вступлении в ПК «ВОСХОД»',
order: 60,
});
port.registerStep({
extension_name: 'chairman',
step_key: 'general_meet',
event_type: 'MEET_DECISION',
vars_field: 'general_meet',
generator: 'meet',
default_title: 'Утверждение общим собранием',
order: 70,
});
}
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import {
EXTENSION_REPOSITORY,
ExtensionDomainRepository,
@@ -8,6 +8,10 @@ import type { IConfig } from '../../chairman-extension.module';
import type { ExtensionDomainEntity } from '~/domain/extension/entities/extension-domain.entity';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { DecisionTrackedEvent } from '~/domain/decision-tracking/events/decision-tracked.event';
import {
ONBOARDING_COMPLETED_EVENT,
type OnboardingCompletedPayload,
} from '~/domain/onboarding/events/onboarding-completed.event';
/**
* Сервис обработки событий онбординга председателя
@@ -17,11 +21,26 @@ import { DecisionTrackedEvent } from '~/domain/decision-tracking/events/decision
export class ChairmanOnboardingEventsService {
constructor(
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository<IConfig>,
private readonly logger: WinstonLoggerService
private readonly logger: WinstonLoggerService,
private readonly eventEmitter: EventEmitter2
) {
this.logger.setContext(ChairmanOnboardingEventsService.name);
}
/**
* Все 7 шагов L1-онбординга chairman, по чьим _done флагам определяется
* завершённость L1 кооператива.
*/
private static readonly L1_DONE_FLAGS: ReadonlyArray<keyof IConfig> = [
'onboarding_wallet_agreement_done',
'onboarding_signature_agreement_done',
'onboarding_privacy_agreement_done',
'onboarding_user_agreement_done',
'onboarding_participant_application_done',
'onboarding_voskhod_membership_done',
'onboarding_general_meet_done',
];
private async load(): Promise<ExtensionDomainEntity<IConfig> | null> {
return this.extensionRepository.findByName('chairman');
}
@@ -65,20 +84,43 @@ export class ChairmanOnboardingEventsService {
return;
}
const wasAlreadyDone = !!(cfg as any)[flagKey];
// Обновляем флаг выполнения шага
const patch: Partial<IConfig> = {
[flagKey]: true,
};
const updated: ExtensionDomainEntity<IConfig> = { ...plugin, config: { ...cfg, ...patch } };
const updatedConfig = { ...cfg, ...patch };
const updated: ExtensionDomainEntity<IConfig> = { ...plugin, config: updatedConfig };
await this.extensionRepository.update(updated);
this.logger.info(`Онбординг обновлён: ${flagKey} = true (решение ${result.decision_id})`);
// L1 auto-restart: если переход false → true сделал L1 полностью
// завершённым, эмиттим ONBOARDING_COMPLETED_EVENT.
// У chairman нет L2-оферт в реестре сейчас, но событие важно как
// сигнал «L1 кооператива закрыт» — раздел 7.1 C28-10.
if (!wasAlreadyDone && this.isL1Complete(updatedConfig as IConfig)) {
this.logger.info(
'[ONBOARDING_COMPLETED] chairman L1 завершён последним шагом, эмиттим событие'
);
this.eventEmitter.emit(ONBOARDING_COMPLETED_EVENT, {
extension_name: 'chairman',
} satisfies OnboardingCompletedPayload);
}
} catch (error: any) {
this.logger.error(`Ошибка при обработке события отслеживания решения: ${error.message}`, error.stack);
}
}
/**
* Проверка: все 7 шагов L1-онбординга chairman завершены.
*/
private isL1Complete(cfg: IConfig): boolean {
return ChairmanOnboardingEventsService.L1_DONE_FLAGS.every((flag) => !!(cfg as any)[flag]);
}
/**
* Маппинг шага онбординга в ключ флага конфигурации
*/
@@ -54,6 +54,12 @@ import { DecisionTrackingInfrastructureModule } from '~/infrastructure/decision-
// Символы для DI
import { APPROVAL_REPOSITORY } from './domain/repositories/approval.repository';
import { CHAIRMAN_BLOCKCHAIN_PORT } from './domain/interfaces/chairman-blockchain.port';
import { computeOnboardingExpiresAt } from '~/domain/onboarding/constants/onboarding-ttl';
import {
ONBOARDING_STEP_REGISTRATION_PORT,
type OnboardingStepRegistrationPort,
} from '~/domain/onboarding/ports/onboarding-step-registration.port';
import { registerChairmanOnboardingSteps } from './application/onboarding/register-chairman-onboarding-steps';
// Функция для описания полей в схеме конфигурации
function describeField(description: DeserializedDescriptionOfExtension): string {
@@ -194,6 +200,8 @@ export class ChairmanPlugin extends BaseExtModule {
@Inject(EXTENSION_REPOSITORY) private readonly extensionRepository: ExtensionDomainRepository<IConfig>,
@Inject(LOG_EXTENSION_REPOSITORY) private readonly logExtensionRepository: LogExtensionDomainRepository<ILog>,
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort,
@Inject(ONBOARDING_STEP_REGISTRATION_PORT)
private readonly onboardingStepRegistration: OnboardingStepRegistrationPort,
private readonly decisionExpiredNotificationService: DecisionExpiredNotificationService,
private readonly logger: WinstonLoggerService
) {
@@ -226,8 +234,7 @@ export class ChairmanPlugin extends BaseExtModule {
}
if (!this.plugin.config.onboarding_expire_at) {
const started = new Date(this.plugin.config.onboarding_init_at || nowIso);
const expire = new Date(started.getTime() + 30 * 24 * 60 * 60 * 1000);
this.plugin.config.onboarding_expire_at = expire.toISOString();
this.plugin.config.onboarding_expire_at = computeOnboardingExpiresAt(started);
needUpdate = true;
}
@@ -237,6 +244,9 @@ export class ChairmanPlugin extends BaseExtModule {
this.logger.info(`Инициализация ${this.name} с конфигурацией`, this.plugin.config);
// Регистрация шагов онбординга в платформенном реестре
registerChairmanOnboardingSteps(this.onboardingStepRegistration);
// Инициализация сервиса проверки истекших решений
await this.decisionExpiredNotificationService.initialize(this.plugin);
}
@@ -0,0 +1,169 @@
# FileStorage
Универсальное файловое хранилище контура кооператива (MinIO в v1, готовность к нативному S3).
Подробности и архитектура — req **«SPEC: файловое хранилище v1»** в blago.
## Когда использовать
Расширению нужно положить и потом отдать пайщику бинарный файл — изображение, PDF, в перспективе
любой другой blob. Не катить свой S3-клиент и не плодить ad-hoc эндпоинты.
## Минимальный пример
### 1. Сервис расширения декларирует свой бакет
```ts
// extensions/stol-zakazov/order-images.service.ts
import { Injectable } from '@nestjs/common';
import {
InjectBucket,
UseBucket,
type InterFileStorageBucket,
} from '~/infrastructure/file-storage';
import type { FileUpload } from 'graphql-upload';
const MB = 1024 * 1024;
@UseBucket({
name: 'stol-zakazov:images',
maxBytes: 10 * MB,
allowedMime: ['image/jpeg', 'image/png', 'image/webp'],
metadataSchema: { ownerId: 'required', orderId: 'required' },
defaultUrlTtlSeconds: 600,
})
@Injectable()
export class OrderImagesService {
constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
async attach(orderId: string, ownerId: string, upload: FileUpload): Promise<string> {
const key = `orders/${orderId}/main.${ext(upload.mimetype)}`;
await this.bucket.put(key, upload.createReadStream(), {
contentType: upload.mimetype,
metadata: { ownerId, orderId },
});
return key;
}
async displayUrl(key: string): Promise<string> {
return this.bucket.getReadUrl(key);
}
async remove(key: string): Promise<void> {
await this.bucket.delete(key); // идемпотентно
}
}
```
**Конвенция:** один бакет — один сервис. Нужен второй бакет — сделай второй сервис.
### 2. Модуль расширения регистрирует сервис в `forFeature`
```ts
// extensions/stol-zakazov/stol-zakazov.module.ts
import { Module } from '@nestjs/common';
import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
import { OrderImagesService } from './order-images.service';
@Module({
imports: [FileStorageInfrastructureModule.forFeature([OrderImagesService])],
providers: [OrderImagesService],
exports: [OrderImagesService],
})
export class StolZakazovModule {}
```
`forFeature` читает `@UseBucket` метадату каждого класса и провайдит ему конкретный
`InterFileStorageBucket` (с уже подмешанным префиксом). Корневой `forRoot(...)` уже подключён
в `app.module.ts`.
### 3. GraphQL — обычная мутация с Upload и поле URL в ответе
```graphql
type Mutation {
createOrderItem(input: CreateOrderItemInput!): OrderItem!
}
input CreateOrderItemInput {
title: String!
image: Upload!
}
type OrderItem {
id: ID!
title: String!
imageUrl: String! # резолвер: bucket.getReadUrl(key) после ACL-проверки
}
```
UI ходит обычным `<img src={imageUrl}>`. Никакого нового REST-слоя для GraphQL-клиента —
служебная ручка `/api/storage/...` живёт сама и видит только подписанные URL.
## Операции
| Метод | Что делает | Гарантии |
|---|---|---|
| `put(key, body, opts)` | Загружает объект | Перезапись по тому же ключу, валидация `maxBytes`/`allowedMime`/`metadataSchema` |
| `getReadUrl(key, opts?)` | Короткоживущий URL чтения | HMAC-signed URL контроллера; TTL по умолчанию из `defaultUrlTtlSeconds` спеки |
| `delete(key)` | Удалить | Идемпотентно (нет объекта = успех) |
| `head(key)` | Метаданные без скачивания | Бросает `InterFileStorageObjectNotFoundError` если нет |
`body` принимает `Uint8Array` или `ReadableStream<Uint8Array>` (graphql-upload `createReadStream()`
также подходит — duck-typed, проверяется наличие `getReader()` или конвертируется адаптером).
## Ошибки
Всё — наследники `InterFileStorageError`. Доменный код мапит на свои коды:
- `InterFileStorageObjectNotFoundError` — нет объекта (`head`)
- `InterFileStorageObjectTooLargeError` — превышен `maxBytes`
- `InterFileStorageMimeRejectedError``contentType` не в `allowedMime`
- `InterFileStorageMetadataValidationError` — нет required-метаданного
- `InterFileStorageBackendUnavailableError` — недоступен MinIO/S3
- `InterFileStorageBucketNotConfiguredError` — невалидная спека бакета
## Конфигурация (env)
| Переменная | Default | Заметка |
|---|---|---|
| `MINIO_ENDPOINT` | `http://minio:9000` | Compose service name; для запуска контроллера на хосте — `http://127.0.0.1:9000` |
| `MINIO_ACCESS_KEY` | `minioadmin` | Только dev; на prod подменяется плейбуком |
| `MINIO_SECRET_KEY` | `minioadmin` | То же |
| `MINIO_BUCKET` | `coop-${COOPNAME}` | Физический бакет, идемпотентно создаётся на bootstrap |
| `FILE_STORAGE_SIGNING_SECRET` | `SERVER_SECRET` | HMAC-секрет; на prod — отдельный |
| `FILE_STORAGE_PUBLIC_BASE_URL` | `BACKEND_URL` | База URL для read-ссылок |
## Тесты
```bash
# Юнит (моки)
cd components/controller && npx jest src/infrastructure/file-storage/ -i
# Интеграционные против реального MinIO
docker compose up -d minio # из mono-ai-3 root
cd components/controller
npm run test:integration:file-storage
```
Тесты пропускаются автоматически (с диагностическим warning), если MinIO недоступен на
`FILE_STORAGE_TEST_MINIO_ENDPOINT` (default `http://localhost:9000`).
## Что в коробке (файлы модуля)
- `file-storage.config.ts``FileStorageInfrastructureOptions` + токен `FILE_STORAGE_OPTIONS`
- `bucket-registry.ts` — singleton, накапливает `BucketSpec` от декораторов
- `use-bucket.decorator.ts``@UseBucket`/`@InjectBucket` + `bucketTokenFor`
- `minio-file-storage.adapter.ts` — реализация `InterFileStoragePort` поверх `@aws-sdk/client-s3`,
валидация спеки, `OnApplicationBootstrap` с `ensureBucketExists`
- `signing.ts``signReadUrl` / `verifyReadUrl` (HMAC-SHA256, constant-time)
- `file-storage-http.controller.ts``GET /api/storage/:bucket/:key` с проверкой подписи
- `file-storage-infrastructure.module.ts``FileStorageModule.forRoot(...) / forFeature(consumers)`
## Не входит в v1
- `getWriteUrl()` (presigned PUT)
- `list(prefix)`, `copy(src, dst)`
- Lifecycle/retention на уровне порта
- Привязка URL к userId (отдельный режим, если появится особо приватный контент)
- Антивирусное сканирование
См. §11 спеки.
@@ -0,0 +1,42 @@
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
describe('BucketRegistry', () => {
beforeEach(() => {
BucketRegistry._resetForTests();
});
const spec = (name: string): InterFileStorageBucketSpec => ({
name,
maxBytes: 1024,
allowedMime: ['image/jpeg'],
});
it('add + get возвращает зарегистрированную спеку', () => {
class A {}
BucketRegistry.add(A, spec('ext-a:images'));
expect(BucketRegistry.get(A)?.name).toBe('ext-a:images');
});
it('повторная регистрация того же класса под тем же именем — no-op (идемпотентно)', () => {
class A {}
BucketRegistry.add(A, spec('ext-a:images'));
expect(() => BucketRegistry.add(A, spec('ext-a:images'))).not.toThrow();
});
it('повторная регистрация того же класса под другим именем — выбрасывает', () => {
class A {}
BucketRegistry.add(A, spec('ext-a:images'));
expect(() => BucketRegistry.add(A, spec('ext-a:other'))).toThrow(/уже зарегистрирован/);
});
it('list возвращает все зарегистрированные пары', () => {
class A {}
class B {}
BucketRegistry.add(A, spec('ext-a:images'));
BucketRegistry.add(B, spec('ext-b:receipts'));
const list = BucketRegistry.list();
expect(list).toHaveLength(2);
expect(list.map((r) => r.spec.name).sort()).toEqual(['ext-a:images', 'ext-b:receipts']);
});
});
@@ -0,0 +1,39 @@
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
/**
* Глобальный реестр бакетов, наполняется декоратором `@UseBucket` на этапе загрузки модулей.
* Используется `FileStorageInfrastructureModule.forFeature(...)` для wire-up per-class providers
* и `MinioFileStorageAdapter.onApplicationBootstrap` для логирования / диагностики.
*/
export interface RegisteredBucket {
readonly cls: { readonly name: string };
readonly spec: InterFileStorageBucketSpec;
}
const REGISTERED = new Map<{ name: string }, InterFileStorageBucketSpec>();
export const BucketRegistry = {
add(cls: { name: string }, spec: InterFileStorageBucketSpec): void {
const existing = REGISTERED.get(cls);
if (existing && existing.name !== spec.name) {
throw new Error(
`BucketRegistry: класс ${cls.name} уже зарегистрирован под бакетом '${existing.name}', ` +
`повторная регистрация под '${spec.name}' запрещена`,
);
}
REGISTERED.set(cls, spec);
},
get(cls: { name: string }): InterFileStorageBucketSpec | undefined {
return REGISTERED.get(cls);
},
list(): readonly RegisteredBucket[] {
return Array.from(REGISTERED.entries()).map(([cls, spec]) => ({ cls, spec }));
},
/** Только для тестов: очистка реестра между сценариями. */
_resetForTests(): void {
REGISTERED.clear();
},
};
@@ -0,0 +1,197 @@
import 'reflect-metadata';
import { Readable } from 'stream';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageObjectNotFoundError,
} from '@coopenomics/inter';
import {
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { FileStorageHttpController } from './file-storage-http.controller';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { signReadUrl } from './signing';
const OPTS: FileStorageInfrastructureOptions = {
endpoint: 'http://minio:9000',
accessKey: 'AKIA-test',
secretKey: 'sk-test',
bucket: 'coop-test',
signingSecret: 'signing-secret-32-bytes-aaaaaaaa',
publicBaseUrl: 'https://test.example.org',
};
interface AdapterStub {
fetchObjectForReadProxy: jest.Mock;
}
async function makeApp(stub: AdapterStub): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
controllers: [FileStorageHttpController],
providers: [
{ provide: FILE_STORAGE_OPTIONS, useValue: OPTS },
{ provide: MinioFileStorageAdapter, useValue: stub },
],
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
return app;
}
function makeStream(bytes: Buffer): Readable {
return Readable.from([bytes]);
}
function makeStub(): AdapterStub {
return { fetchObjectForReadProxy: jest.fn() };
}
function urlFor(bucket: string, key: string, ttlSeconds: number): { path: string; exp: number; sig: string } {
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
const sig = signReadUrl({ bucket, key, expUnix: exp, secret: OPTS.signingSecret });
const encodedKey = key.split('/').map(encodeURIComponent).join('/');
return { path: `/api/storage/${encodeURIComponent(bucket)}/${encodedKey}`, exp, sig };
}
describe('FileStorageHttpController', () => {
let app: INestApplication;
let stub: AdapterStub;
beforeEach(() => {
stub = makeStub();
});
afterEach(async () => {
if (app) await app.close();
});
it('200 + байты при валидной подписи', async () => {
app = await makeApp(stub);
const payload = Buffer.from('hello world');
stub.fetchObjectForReadProxy.mockResolvedValueOnce({
stream: makeStream(payload),
size: payload.length,
contentType: 'image/jpeg',
lastModified: new Date(),
});
const u = urlFor('orders-images', 'orders/42/main.jpg', 60);
const res = await request(app.getHttpServer())
.get(u.path)
.query({ exp: u.exp, sig: u.sig })
.buffer(true)
.parse((response, cb) => {
const chunks: Buffer[] = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/jpeg');
expect(res.headers['content-length']).toBe(String(payload.length));
expect(res.headers['cache-control']).toMatch(/^private, max-age=\d+$/);
expect((res.body as Buffer).equals(payload)).toBe(true);
expect(stub.fetchObjectForReadProxy).toHaveBeenCalledWith('orders-images/orders/42/main.jpg');
});
it('403 при неверной подписи', async () => {
app = await makeApp(stub);
const u = urlFor('orders-images', 'a.jpg', 60);
const res = await request(app.getHttpServer())
.get(u.path)
.query({ exp: u.exp, sig: 'a'.repeat(64) });
expect(res.status).toBe(403);
expect(stub.fetchObjectForReadProxy).not.toHaveBeenCalled();
});
it('403 при истёкшем exp', async () => {
app = await makeApp(stub);
const exp = Math.floor(Date.now() / 1000) - 1;
const sig = signReadUrl({ bucket: 'orders-images', key: 'a.jpg', expUnix: exp, secret: OPTS.signingSecret });
const res = await request(app.getHttpServer())
.get('/api/storage/orders-images/a.jpg')
.query({ exp, sig });
expect(res.status).toBe(403);
expect(stub.fetchObjectForReadProxy).not.toHaveBeenCalled();
});
it('403 при отсутствии exp/sig', async () => {
app = await makeApp(stub);
const res = await request(app.getHttpServer()).get('/api/storage/orders-images/a.jpg');
expect(res.status).toBe(403);
});
it('403 при подмене bucket в URL (HMAC связан с bucket)', async () => {
app = await makeApp(stub);
const exp = Math.floor(Date.now() / 1000) + 60;
const sig = signReadUrl({
bucket: 'orders-images',
key: 'a.jpg',
expUnix: exp,
secret: OPTS.signingSecret,
});
const res = await request(app.getHttpServer())
.get('/api/storage/expenses-receipts/a.jpg')
.query({ exp, sig });
expect(res.status).toBe(403);
});
it('403 при подмене key в URL (HMAC связан с key)', async () => {
app = await makeApp(stub);
const exp = Math.floor(Date.now() / 1000) + 60;
const sig = signReadUrl({
bucket: 'orders-images',
key: 'a.jpg',
expUnix: exp,
secret: OPTS.signingSecret,
});
const res = await request(app.getHttpServer())
.get('/api/storage/orders-images/b.jpg')
.query({ exp, sig });
expect(res.status).toBe(403);
});
it('404 при ObjectNotFoundError от адаптера', async () => {
app = await makeApp(stub);
stub.fetchObjectForReadProxy.mockRejectedValueOnce(
new InterFileStorageObjectNotFoundError('missing'),
);
const u = urlFor('orders-images', 'missing.jpg', 60);
const res = await request(app.getHttpServer()).get(u.path).query({ exp: u.exp, sig: u.sig });
expect(res.status).toBe(404);
});
it('502 при BackendUnavailableError от адаптера', async () => {
app = await makeApp(stub);
stub.fetchObjectForReadProxy.mockRejectedValueOnce(
new InterFileStorageBackendUnavailableError('backend down'),
);
const u = urlFor('orders-images', 'x.jpg', 60);
const res = await request(app.getHttpServer()).get(u.path).query({ exp: u.exp, sig: u.sig });
expect(res.status).toBe(502);
});
it('правильно парсит составной ключ с / и спецсимволами', async () => {
app = await makeApp(stub);
const payload = Buffer.from('xy');
stub.fetchObjectForReadProxy.mockResolvedValueOnce({
stream: makeStream(payload),
size: payload.length,
contentType: 'application/pdf',
lastModified: new Date(),
});
const key = 'reports/q1 2026/r 1.pdf';
const u = urlFor('expenses-receipts', key, 60);
const res = await request(app.getHttpServer())
.get(u.path)
.query({ exp: u.exp, sig: u.sig });
expect(res.status).toBe(200);
expect(stub.fetchObjectForReadProxy).toHaveBeenCalledWith(`expenses-receipts/${key}`);
});
});
@@ -0,0 +1,131 @@
import {
Controller,
Get,
HttpStatus,
Inject,
Logger,
Param,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageObjectNotFoundError,
} from '@coopenomics/inter';
import {
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { verifyReadUrl } from './signing';
/**
* Служебная ручка отдачи объектов по HMAC-signed URL.
*
* Формат URL формирует `MinioFileStorageAdapter.getReadUrl`:
* `<publicBaseUrl>/api/storage/:bucket/:key?exp=<unix-ts>&sig=<hmac-hex>`
*
* - `:bucket` публичная форма имени бакета (`<extension>-<purpose>`).
* - `:key` caller-defined ключ; может содержать `/`, кодируется посегментно.
* - `exp` unix-timestamp истечения; `now < exp` обязательно.
* - `sig` `HMAC-SHA256(secret, "<bucket>\n<key>\n<exp>")`, hex; constant-time сверка.
*
* ACL принимается ДО получения URL на стороне доменного резолвера. Любой держатель URL до
* истечения TTL может скачать. Утечка утечка контента до `exp`.
*/
@Controller('api/storage')
export class FileStorageHttpController {
private readonly logger = new Logger(FileStorageHttpController.name);
constructor(
@Inject(FILE_STORAGE_OPTIONS)
private readonly opts: FileStorageInfrastructureOptions,
private readonly adapter: MinioFileStorageAdapter,
) {}
@Get(':bucket/*')
async serve(
@Param('bucket') bucketRaw: string,
@Query('exp') expRaw: string | undefined,
@Query('sig') sig: string | undefined,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const bucket = safeDecodeURIComponent(bucketRaw);
const restRaw = (req.params as Record<string, string>)['0'] ?? '';
const key = decodeKeyFromPath(restRaw);
const exp = Number(expRaw);
if (!Number.isFinite(exp) || !sig || !bucket || !key) {
res.status(HttpStatus.FORBIDDEN).end();
return;
}
const nowUnix = Math.floor(Date.now() / 1000);
if (nowUnix >= exp) {
res.status(HttpStatus.FORBIDDEN).end();
return;
}
const ok = verifyReadUrl({
bucket,
key,
expUnix: exp,
sig,
secret: this.opts.signingSecret,
});
if (!ok) {
res.status(HttpStatus.FORBIDDEN).end();
return;
}
const physicalKey = `${bucket}/${key}`;
let obj;
try {
obj = await this.adapter.fetchObjectForReadProxy(physicalKey);
} catch (e) {
if (e instanceof InterFileStorageObjectNotFoundError) {
res.status(HttpStatus.NOT_FOUND).end();
return;
}
if (e instanceof InterFileStorageBackendUnavailableError) {
this.logger.warn(`backend недоступен на ${physicalKey}: ${(e as Error).message}`);
res.status(HttpStatus.BAD_GATEWAY).end();
return;
}
throw e;
}
const remaining = Math.max(0, exp - nowUnix);
res.setHeader('Content-Type', obj.contentType);
if (obj.size > 0) {
res.setHeader('Content-Length', String(obj.size));
}
res.setHeader('Cache-Control', `private, max-age=${remaining}`);
res.status(HttpStatus.OK);
obj.stream.on('error', (err) => {
this.logger.warn(`stream error на ${physicalKey}: ${err.message}`);
if (!res.headersSent) {
res.status(HttpStatus.BAD_GATEWAY).end();
} else {
res.end();
}
});
obj.stream.pipe(res);
}
}
function safeDecodeURIComponent(s: string): string {
try {
return decodeURIComponent(s);
} catch {
return '';
}
}
function decodeKeyFromPath(rest: string): string {
if (!rest) return '';
return rest.split('/').map(safeDecodeURIComponent).join('/');
}

Some files were not shown because too many files have changed in this diff Show More