Compare commits

...

32 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
Alex Ant 3561888781 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-12 11:14:51 +05: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
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
Alex Ant bb1d8558e7 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-11 23:11:03 +05:00
Alex Ant a575da57f8 Merge branch 'dev' of github.com:coopenomics/mono into dev 2026-05-11 23:08:28 +05:00
131 changed files with 5657 additions and 1380 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.11-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.11-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.11-4",
"version": "2026.5.14-2",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
@@ -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,
@@ -36,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-запись не имеет смысла");
@@ -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 },
}};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.5.11-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: {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "create-nodejs-express-app",
"version": "2026.5.11-4",
"version": "2026.5.14-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.5.11-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.11-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",
+56 -73
View File
@@ -1805,7 +1805,7 @@ type CapitalCommit {
data: JSON
"""Описание коммита"""
description: String!
description: String
"""Отображаемое имя пользователя"""
display_name: String
@@ -1814,7 +1814,7 @@ type CapitalCommit {
id: Int
"""Метаданные коммита"""
meta: String!
meta: String
"""Флаг присутствия записи в блокчейне"""
present: Boolean!
@@ -6161,6 +6161,53 @@ input GenerationContractSignedMetaDocumentInput {
version: String!
}
input GenerationConvertStatementGenerateDocumentInput {
"""Сумма для перевода в программу «Благорост»"""
blagorost_wallet_amount: String!
"""Номер блока, на котором был создан документ"""
block_num: Int
"""Название кооператива, связанное с документом"""
coopname: String!
"""Дата и время создания документа"""
created_at: String
"""Имя генератора, использованного для создания документа"""
generator: String
"""Язык документа"""
lang: String
"""Ссылки, связанные с документом"""
links: [String!]
"""Сумма для перевода в Цифровой Кошелёк"""
main_wallet_amount: String!
"""Хэш проекта"""
project_hash: String!
"""Часовой пояс, в котором был создан документ"""
timezone: String
"""Название документа"""
title: String
"""Признак перевода в программу «Благорост»"""
to_blagorost: Boolean!
"""Признак перевода в Цифровой Кошелёк"""
to_wallet: Boolean!
"""Имя пользователя, создавшего документ"""
username: String!
"""Версия генератора, использованного для создания документа"""
version: String
}
input GenerationMoneyInvestStatementGenerateDocumentInput {
"""Сумма инвестирования"""
amount: String!
@@ -6272,56 +6319,6 @@ input GenerationMoneyInvestStatementSignedMetaDocumentInput {
version: String!
}
input GenerationToMainWalletConvertStatementGenerateDocumentInput {
"""Хэш приложения"""
appendix_hash: String!
"""Сумма для перевода на благорост кошелек"""
blagorost_wallet_amount: String!
"""Номер блока, на котором был создан документ"""
block_num: Int
"""Название кооператива, связанное с документом"""
coopname: String!
"""Дата и время создания документа"""
created_at: String
"""Имя генератора, использованного для создания документа"""
generator: String
"""Язык документа"""
lang: String
"""Ссылки, связанные с документом"""
links: [String!]
"""Сумма для перевода на основной кошелек"""
main_wallet_amount: String!
"""Хэш проекта"""
project_hash: String!
"""Часовой пояс, в котором был создан документ"""
timezone: String
"""Название документа"""
title: String
"""Перевод на благорост кошелек"""
to_blagorost: Boolean!
"""Перевод на основной кошелек"""
to_wallet: Boolean!
"""Имя пользователя, создавшего документ"""
username: String!
"""Версия генератора, использованного для создания документа"""
version: String
}
input GetAccountInput {
"""Имя аккаунта пользователя"""
username: String!
@@ -7685,6 +7682,13 @@ type Mutation {
"""
capitalGenerateGenerationContract(data: GenerationContractGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationConvertStatement(data: GenerationConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление об инвестировании в генерацию
@@ -7713,27 +7717,6 @@ type Mutation {
"""
capitalGenerateGenerationPropertyInvestStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации из генерации в благорост
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationToCapitalizationConvertStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации из генерации в основной кошелек
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationToMainWalletConvertStatement(data: GenerationToMainWalletConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать заявление о конвертации из генерации в проектный кошелек
Требуемые роли: chairman, member.
"""
capitalGenerateGenerationToProjectConvertStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
"""
Сгенерировать решение о получении займа
+9
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';
@@ -102,6 +103,14 @@ 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,
@@ -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;
}
@@ -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);
}
+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,
},
};
@@ -25,7 +25,7 @@ export interface AgreementRegistrationSpec {
/**
* Тип соглашения для on-chain `agreements` (sendAgreement action).
* Расширение предоставляет своё значение (например 'blagorost' для capital,
* Расширение предоставляет своё значение (например 'capital' для capital,
* 'order_table' для Стола заказов).
*/
agreement_type: string;
@@ -1,7 +1,7 @@
/**
* Типы платформенных соглашений для отправки в блокчейн (sendAgreement).
*
* Типы оферт расширений (capital → 'blagorost', 'generator' и т.п.)
* Типы оферт расширений (capital → 'capital', 'generator' и т.п.)
* не входят в этот enum: расширения сами задают строковые agreement_type
* через AgreementRegistrationPort и обращаются к sendAgreement напрямую
* с этими значениями.
@@ -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,
@@ -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
@@ -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,
@@ -23,7 +23,10 @@ export const GENERATOR_OFFER_AGREEMENT_ID = 'generator_offer';
export const BLAGOROST_OFFER_AGREEMENT_ID = 'blagorost_offer';
export const GENERATOR_AGREEMENT_TYPE = 'generator';
export const BLAGOROST_AGREEMENT_TYPE = 'blagorost';
// 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,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('/');
}
@@ -0,0 +1,58 @@
import { DynamicModule, Global, Module, type Type } from '@nestjs/common';
import { INTER_FILE_STORAGE, type InterFileStoragePort } from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
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 { bucketTokenFor } from './use-bucket.decorator';
/**
* Динамический модуль файлового хранилища.
*
* - `forRoot(options)` — провайдит адаптер `InterFileStoragePort` (токен `INTER_FILE_STORAGE`)
* и стартует `OnApplicationBootstrap` хук с `ensureBucketExists`. Глобальный — токен
* доступен `forFeature`-ам без явного импорта.
* - `forFeature(consumers)` — для каждого `@UseBucket`-класса регистрирует фабрику
* `bucketTokenFor(class)`, которая отдаёт `InterFileStorageBucket`. Импортируется в модуле
* расширения, где живут эти сервисы.
*/
@Global()
@Module({})
export class FileStorageInfrastructureModule {
static forRoot(options: FileStorageInfrastructureOptions): DynamicModule {
return {
module: FileStorageInfrastructureModule,
controllers: [FileStorageHttpController],
providers: [
{ provide: FILE_STORAGE_OPTIONS, useValue: options },
MinioFileStorageAdapter,
{ provide: INTER_FILE_STORAGE, useExisting: MinioFileStorageAdapter },
],
exports: [INTER_FILE_STORAGE, MinioFileStorageAdapter],
};
}
static forFeature(consumers: ReadonlyArray<Type<unknown>>): DynamicModule {
const providers = consumers.map((cls) => {
const spec = BucketRegistry.get(cls);
if (!spec) {
throw new Error(
`FileStorageInfrastructureModule.forFeature: класс ${cls.name} не помечен @UseBucket`,
);
}
return {
provide: bucketTokenFor(cls),
useFactory: (port: InterFileStoragePort) => port.getBucket(spec),
inject: [INTER_FILE_STORAGE],
};
});
return {
module: FileStorageInfrastructureModule,
providers,
exports: providers.map((p) => p.provide),
};
}
}
@@ -0,0 +1,26 @@
/**
* Конфигурация подключения к MinIO/S3 и подписания read-URL.
* Передаётся в `FileStorageInfrastructureModule.forRoot(options)`.
*/
export interface FileStorageInfrastructureOptions {
/** URL S3-совместимого бэкенда. Для MinIO — `http://minio:9000`. Пусто/undefined = file storage отключён (no-op). */
endpoint?: string;
accessKey: string;
secretKey: string;
/** Имя физического бакета на сервере (например, `coop-voskhod`). */
bucket: string;
/** Секрет для HMAC-подписи read-URL служебной ручки контроллера. */
signingSecret: string;
/** База публичного URL контроллера (например, `https://voskhod.example.org`). */
publicBaseUrl: string;
/** AWS region. Для MinIO любое непустое значение. По умолчанию `us-east-1`. */
region?: string;
/** Path-style addressing (true для MinIO, false для AWS S3). По умолчанию true. */
forcePathStyle?: boolean;
/** TTL по умолчанию для read-URL (сек). Перекрывается `BucketSpec.defaultUrlTtlSeconds`. По умолчанию 600. */
defaultUrlTtlSeconds?: number;
}
export const FILE_STORAGE_OPTIONS = Symbol.for('FileStorage.Options');
export const FILE_STORAGE_DEFAULT_URL_TTL_SECONDS = 600;
@@ -0,0 +1,14 @@
export {
FILE_STORAGE_DEFAULT_URL_TTL_SECONDS,
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
export { BucketRegistry, type RegisteredBucket } from './bucket-registry';
export { InjectBucket, UseBucket, bucketTokenFor } from './use-bucket.decorator';
export {
MinioFileStorageAdapter,
type FileStorageObjectStream,
} from './minio-file-storage.adapter';
export { FileStorageHttpController } from './file-storage-http.controller';
export { FileStorageInfrastructureModule } from './file-storage-infrastructure.module';
export { signReadUrl, verifyReadUrl } from './signing';
@@ -0,0 +1,365 @@
import 'reflect-metadata';
import {
CreateBucketCommand,
DeleteObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
} from '@aws-sdk/client-s3';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageBucketNotConfiguredError,
InterFileStorageMetadataValidationError,
InterFileStorageMimeRejectedError,
InterFileStorageObjectNotFoundError,
InterFileStorageObjectTooLargeError,
type InterFileStorageBucketSpec,
} from '@coopenomics/inter';
import type { FileStorageInfrastructureOptions } from './file-storage.config';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { verifyReadUrl } from './signing';
const BASE_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',
};
const SPEC: InterFileStorageBucketSpec = {
name: 'orders:images',
maxBytes: 1024,
allowedMime: ['image/jpeg', 'image/png'],
metadataSchema: {
ownerId: 'required',
altText: 'optional',
},
};
function makeAdapter(opts: FileStorageInfrastructureOptions = BASE_OPTS): {
adapter: MinioFileStorageAdapter;
send: jest.Mock;
} {
const adapter = new MinioFileStorageAdapter(opts);
const send = jest.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(adapter as any).s3 = { send };
return { adapter, send };
}
describe('MinioFileStorageAdapter — getBucket валидация спеки', () => {
it('отклоняет name без двоеточия', () => {
const { adapter } = makeAdapter();
expect(() => adapter.getBucket({ ...SPEC, name: 'no-colon' })).toThrow(
InterFileStorageBucketNotConfiguredError,
);
});
it('отклоняет неположительный maxBytes', () => {
const { adapter } = makeAdapter();
expect(() => adapter.getBucket({ ...SPEC, maxBytes: 0 })).toThrow(
InterFileStorageBucketNotConfiguredError,
);
});
it('отклоняет пустой allowedMime', () => {
const { adapter } = makeAdapter();
expect(() => adapter.getBucket({ ...SPEC, allowedMime: [] })).toThrow(
InterFileStorageBucketNotConfiguredError,
);
});
});
describe('MinioFileStorageAdapter — put', () => {
it('успешный put передаёт верные Bucket/Key/ContentType/Metadata в PutObjectCommand', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({ ETag: '"abc123"' });
const bucket = adapter.getBucket(SPEC);
const body = new Uint8Array([1, 2, 3, 4]);
const result = await bucket.put('orders/42/main.jpg', body, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u-1' },
});
expect(send).toHaveBeenCalledTimes(1);
const cmd = send.mock.calls[0][0] as PutObjectCommand;
expect(cmd).toBeInstanceOf(PutObjectCommand);
expect(cmd.input.Bucket).toBe('coop-test');
expect(cmd.input.Key).toBe('orders-images/orders/42/main.jpg');
expect(cmd.input.ContentType).toBe('image/jpeg');
expect(cmd.input.ContentLength).toBe(4);
expect(cmd.input.Metadata).toEqual({ ownerId: 'u-1' });
expect(result.key).toBe('orders/42/main.jpg');
expect(result.etag).toBe('abc123');
expect(result.size).toBe(4);
});
it('отклоняет put с неподходящим MIME без обращения к S3', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('orders/x/main.exe', new Uint8Array([0]), {
contentType: 'application/octet-stream',
metadata: { ownerId: 'u-1' },
}),
).rejects.toBeInstanceOf(InterFileStorageMimeRejectedError);
expect(send).not.toHaveBeenCalled();
});
it('отклоняет put сверх maxBytes (Uint8Array) без обращения к S3', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const body = new Uint8Array(SPEC.maxBytes + 1);
await expect(
bucket.put('big.jpg', body, { contentType: 'image/jpeg', metadata: { ownerId: 'u-1' } }),
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
expect(send).not.toHaveBeenCalled();
});
it('отклоняет put сверх maxBytes (ReadableStream) до отправки', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const chunks: Uint8Array[] = [
new Uint8Array(SPEC.maxBytes - 100),
new Uint8Array(200), // overshoot at second chunk
];
const stream = makeWebStream(chunks) as unknown as ReadableStream<Uint8Array>;
await expect(
bucket.put('big-stream.jpg', stream, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u-1' },
}),
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
expect(send).not.toHaveBeenCalled();
});
it('отклоняет put без обязательного metadata-поля', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('x.jpg', new Uint8Array([1]), { contentType: 'image/jpeg', metadata: {} }),
).rejects.toBeInstanceOf(InterFileStorageMetadataValidationError);
expect(send).not.toHaveBeenCalled();
});
it('путь успешного потока — стрим читается, размер контролируется, S3 получает Buffer', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({ ETag: '"e1"' });
const bucket = adapter.getBucket(SPEC);
const stream = makeWebStream([
new Uint8Array([10, 20]),
new Uint8Array([30]),
]) as unknown as ReadableStream<Uint8Array>;
const r = await bucket.put('s.jpg', stream, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
});
expect(r.size).toBe(3);
const cmd = send.mock.calls[0][0] as PutObjectCommand;
const body = cmd.input.Body as Buffer;
expect(body.length).toBe(3);
expect(body[0]).toBe(10);
expect(body[2]).toBe(30);
});
it('оборачивает backend-ошибку put в BackendUnavailableError', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('connection refused'));
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('x.jpg', new Uint8Array([1]), {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
}),
).rejects.toBeInstanceOf(InterFileStorageBackendUnavailableError);
});
});
describe('MinioFileStorageAdapter — head', () => {
it('возвращает метаданные при успешном HEAD', async () => {
const { adapter, send } = makeAdapter();
const lastModified = new Date('2026-04-23T10:00:00Z');
send.mockResolvedValueOnce({
ContentLength: 42,
ETag: '"deadbeef"',
ContentType: 'image/png',
LastModified: lastModified,
Metadata: { ownerid: 'u-1' },
});
const bucket = adapter.getBucket(SPEC);
const r = await bucket.head('a/b.png');
expect((send.mock.calls[0][0] as HeadObjectCommand).input.Key).toBe('orders-images/a/b.png');
expect(r.size).toBe(42);
expect(r.etag).toBe('deadbeef');
expect(r.contentType).toBe('image/png');
expect(r.lastModified).toEqual(lastModified);
expect(r.metadata.ownerid).toBe('u-1');
});
it('бросает ObjectNotFoundError при S3-ошибке NotFound', async () => {
const { adapter, send } = makeAdapter();
const err = Object.assign(new Error('not found'), {
name: 'NotFound',
$metadata: { httpStatusCode: 404 },
});
send.mockRejectedValueOnce(err);
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('missing.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
it('бросает ObjectNotFoundError при ошибке NoSuchKey', async () => {
const { adapter, send } = makeAdapter();
const err = Object.assign(new Error('no such key'), { name: 'NoSuchKey' });
send.mockRejectedValueOnce(err);
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('missing.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
it('оборачивает прочие backend-ошибки в BackendUnavailableError', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('timeout'));
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('x.jpg')).rejects.toBeInstanceOf(
InterFileStorageBackendUnavailableError,
);
});
});
describe('MinioFileStorageAdapter — delete', () => {
it('успешный delete вызывает DeleteObjectCommand с правильным Key', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({});
const bucket = adapter.getBucket(SPEC);
await bucket.delete('a/b.jpg');
const cmd = send.mock.calls[0][0] as DeleteObjectCommand;
expect(cmd).toBeInstanceOf(DeleteObjectCommand);
expect(cmd.input.Bucket).toBe('coop-test');
expect(cmd.input.Key).toBe('orders-images/a/b.jpg');
});
it('оборачивает backend-ошибку delete в BackendUnavailableError', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('500'));
const bucket = adapter.getBucket(SPEC);
await expect(bucket.delete('x.jpg')).rejects.toBeInstanceOf(
InterFileStorageBackendUnavailableError,
);
});
});
describe('MinioFileStorageAdapter — getReadUrl', () => {
it('возвращает URL верной структуры с подписью, проходящей verifyReadUrl', async () => {
const { adapter } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const url = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 60 });
const parsed = new URL(url);
expect(parsed.origin).toBe('https://test.example.org');
expect(parsed.pathname).toBe('/api/storage/orders-images/orders/42/main.jpg');
const exp = Number(parsed.searchParams.get('exp'));
const sig = parsed.searchParams.get('sig')!;
const now = Math.floor(Date.now() / 1000);
expect(exp).toBeGreaterThan(now);
expect(exp).toBeLessThanOrEqual(now + 61);
expect(sig).toMatch(/^[0-9a-f]{64}$/);
const ok = verifyReadUrl({
bucket: 'orders-images',
key: 'orders/42/main.jpg',
expUnix: exp,
sig,
secret: BASE_OPTS.signingSecret,
});
expect(ok).toBe(true);
});
it('берёт TTL из defaultUrlTtlSeconds бакета, если не передан', async () => {
const { adapter } = makeAdapter();
const bucket = adapter.getBucket({ ...SPEC, defaultUrlTtlSeconds: 30 });
const url = await bucket.getReadUrl('x.jpg');
const exp = Number(new URL(url).searchParams.get('exp'));
const now = Math.floor(Date.now() / 1000);
expect(exp - now).toBeGreaterThanOrEqual(28);
expect(exp - now).toBeLessThanOrEqual(31);
});
it('экранирует ключ посегментно, сохраняя слэши как разделители', async () => {
const { adapter } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const url = await bucket.getReadUrl('a b/c?d=e/f.jpg');
const parsed = new URL(url);
expect(parsed.pathname).toBe('/api/storage/orders-images/a%20b/c%3Fd%3De/f.jpg');
});
});
describe('MinioFileStorageAdapter — onApplicationBootstrap', () => {
it('пропускает CreateBucket, если HeadBucket прошёл', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({}); // HeadBucket OK
await adapter.onApplicationBootstrap();
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
});
it('создаёт бакет, если HeadBucket вернул 404', async () => {
const { adapter, send } = makeAdapter();
const notFound = Object.assign(new Error('not found'), {
name: 'NotFound',
$metadata: { httpStatusCode: 404 },
});
send.mockRejectedValueOnce(notFound).mockResolvedValueOnce({});
await adapter.onApplicationBootstrap();
expect(send).toHaveBeenCalledTimes(2);
expect(send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
expect(send.mock.calls[1][0]).toBeInstanceOf(CreateBucketCommand);
});
it('бросает BackendUnavailableError, если HeadBucket упал не на 404', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('connection refused'));
await expect(adapter.onApplicationBootstrap()).rejects.toBeInstanceOf(
InterFileStorageBackendUnavailableError,
);
});
});
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeWebStream(chunks: Uint8Array[]): {
getReader: () => { read: () => Promise<{ value?: Uint8Array; done: boolean }>; cancel: () => Promise<void> };
} {
let i = 0;
return {
getReader() {
return {
async read() {
if (i >= chunks.length) return { value: undefined, done: true };
const value = chunks[i++];
return { value, done: false };
},
async cancel() {
/* noop */
},
};
},
};
}
@@ -0,0 +1,385 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import {
CreateBucketCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageBucketNotConfiguredError,
InterFileStorageMetadataValidationError,
InterFileStorageMimeRejectedError,
InterFileStorageObjectNotFoundError,
InterFileStorageObjectTooLargeError,
type InterFileStorageBody,
type InterFileStorageBucket,
type InterFileStorageBucketSpec,
type InterFileStorageGetReadUrlOptions,
type InterFileStorageObjectMetadata,
type InterFileStoragePort,
type InterFileStoragePutOptions,
type InterFileStoragePutResult,
} from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
import {
FILE_STORAGE_DEFAULT_URL_TTL_SECONDS,
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { signReadUrl } from './signing';
/**
* Адаптер `InterFileStoragePort` поверх MinIO/S3. Реализация одна и та же для обоих —
* различается только `endpoint` и `forcePathStyle`. На bootstrap идемпотентно создаёт
* физический бакет, дальше отдаёт `InterFileStorageBucket`-хэндлы по спекам.
*
* URL чтения формируется на собственный домен контроллера (служебная ручка `/api/storage/...`),
* подписывается HMAC-SHA256 секретом из конфига. При переходе на нативный S3 здесь же
* можно начать возвращать настоящие S3 presigned URL — контракт строки не меняется.
*/
@Injectable()
export class MinioFileStorageAdapter implements InterFileStoragePort, OnApplicationBootstrap {
private readonly logger = new Logger(MinioFileStorageAdapter.name);
private readonly s3: S3Client | null;
private readonly enabled: boolean;
constructor(
@Inject(FILE_STORAGE_OPTIONS)
private readonly opts: FileStorageInfrastructureOptions,
) {
this.enabled = typeof opts.endpoint === 'string' && opts.endpoint.length > 0;
this.s3 = this.enabled
? new S3Client({
endpoint: opts.endpoint,
region: opts.region ?? 'us-east-1',
credentials: {
accessKeyId: opts.accessKey,
secretAccessKey: opts.secretKey,
},
forcePathStyle: opts.forcePathStyle ?? true,
})
: null;
}
async onApplicationBootstrap(): Promise<void> {
if (!this.enabled || !this.s3) {
this.logger.warn(
'File storage не сконфигурирован (MINIO_ENDPOINT пуст) — модуль стартует в no-op режиме. ' +
'Загрузка/чтение файлов будут отклонены до настройки бэкенда.',
);
return;
}
await this.ensureBucketExists();
const registered = BucketRegistry.list();
this.logger.log(
`File storage готов. Физический бакет '${this.opts.bucket}', эндпоинт '${this.opts.endpoint}'. ` +
`Зарегистрировано бакетов через @UseBucket: ${registered.length}` +
(registered.length > 0
? ` (${registered.map((r) => `${r.cls.name}${r.spec.name}`).join(', ')})`
: ''),
);
}
private assertEnabled(): S3Client {
if (!this.enabled || !this.s3) {
throw new InterFileStorageBackendUnavailableError(
'File storage не сконфигурирован: MINIO_ENDPOINT пуст. Задайте переменную окружения, чтобы включить загрузку/чтение файлов.',
);
}
return this.s3;
}
private async ensureBucketExists(): Promise<void> {
const s3 = this.assertEnabled();
try {
await s3.send(new HeadBucketCommand({ Bucket: this.opts.bucket }));
return;
} catch (e) {
if (!isNotFound(e)) {
throw new InterFileStorageBackendUnavailableError(
`HeadBucket(${this.opts.bucket}) не удался: ${getMessage(e)}`,
{ cause: asError(e) },
);
}
}
try {
await s3.send(new CreateBucketCommand({ Bucket: this.opts.bucket }));
this.logger.log(`Создан физический бакет '${this.opts.bucket}'`);
} catch (e) {
throw new InterFileStorageBackendUnavailableError(
`CreateBucket(${this.opts.bucket}) не удался: ${getMessage(e)}`,
{ cause: asError(e) },
);
}
}
getBucket(spec: InterFileStorageBucketSpec): InterFileStorageBucket {
const s3 = this.assertEnabled();
validateSpec(spec);
return new MinioBucketHandle(s3, this.opts, spec);
}
/**
* Внутренний fetch для HTTP-ручки `/api/storage/:bucket/:key` (E59-4).
* `physicalKey` — это уже собранный ключ S3-объекта вида `<extension>-<purpose>/<caller-key>`.
* @throws InterFileStorageObjectNotFoundError, InterFileStorageBackendUnavailableError
*/
async fetchObjectForReadProxy(physicalKey: string): Promise<FileStorageObjectStream> {
const s3 = this.assertEnabled();
try {
const r = await s3.send(
new GetObjectCommand({ Bucket: this.opts.bucket, Key: physicalKey }),
);
return {
stream: r.Body as NodeJS.ReadableStream,
size: r.ContentLength ?? 0,
contentType: r.ContentType ?? 'application/octet-stream',
lastModified: r.LastModified ?? new Date(0),
};
} catch (e) {
if (isNotFound(e)) {
throw new InterFileStorageObjectNotFoundError(`Объект '${physicalKey}' не найден`);
}
throw wrapBackendError(e, `getObject '${physicalKey}'`);
}
}
}
class MinioBucketHandle implements InterFileStorageBucket {
private readonly publicBucketName: string;
private readonly defaultTtlSeconds: number;
constructor(
private readonly s3: S3Client,
private readonly opts: FileStorageInfrastructureOptions,
private readonly spec: InterFileStorageBucketSpec,
) {
this.publicBucketName = spec.name.replace(/:/g, '-');
this.defaultTtlSeconds =
spec.defaultUrlTtlSeconds ?? opts.defaultUrlTtlSeconds ?? FILE_STORAGE_DEFAULT_URL_TTL_SECONDS;
}
private physicalKey(key: string): string {
return `${this.publicBucketName}/${key}`;
}
async put(
key: string,
body: InterFileStorageBody,
opts: InterFileStoragePutOptions,
): Promise<InterFileStoragePutResult> {
if (!this.spec.allowedMime.includes(opts.contentType)) {
throw new InterFileStorageMimeRejectedError(
`MIME '${opts.contentType}' не входит в allowedMime бакета '${this.spec.name}'`,
);
}
this.validateMetadata(opts.metadata);
const buffer = await materializeAndCheckSize(body, this.spec.maxBytes, this.spec.name);
try {
const r = await this.s3.send(
new PutObjectCommand({
Bucket: this.opts.bucket,
Key: this.physicalKey(key),
Body: buffer,
ContentType: opts.contentType,
ContentLength: buffer.byteLength,
Metadata: opts.metadata as Record<string, string> | undefined,
}),
);
return {
key,
etag: stripQuotes(r.ETag) ?? '',
size: buffer.byteLength,
};
} catch (e) {
throw wrapBackendError(e, `put '${this.spec.name}/${key}'`);
}
}
async getReadUrl(key: string, opts?: InterFileStorageGetReadUrlOptions): Promise<string> {
const ttl = opts?.ttlSeconds ?? this.defaultTtlSeconds;
const exp = Math.floor(Date.now() / 1000) + ttl;
const sig = signReadUrl({
bucket: this.publicBucketName,
key,
expUnix: exp,
secret: this.opts.signingSecret,
});
const base = this.opts.publicBaseUrl.replace(/\/+$/, '');
return `${base}/api/storage/${encodeURIComponent(this.publicBucketName)}/${encodeKeyForPath(key)}?exp=${exp}&sig=${sig}`;
}
async delete(key: string): Promise<void> {
try {
await this.s3.send(
new DeleteObjectCommand({ Bucket: this.opts.bucket, Key: this.physicalKey(key) }),
);
} catch (e) {
// S3 DeleteObject идемпотентен — отсутствие объекта не ошибка. Сюда попадают только
// реальные backend-фейлы.
throw wrapBackendError(e, `delete '${this.spec.name}/${key}'`);
}
}
async head(key: string): Promise<InterFileStorageObjectMetadata> {
try {
const r = await this.s3.send(
new HeadObjectCommand({ Bucket: this.opts.bucket, Key: this.physicalKey(key) }),
);
return {
size: r.ContentLength ?? 0,
etag: stripQuotes(r.ETag) ?? '',
contentType: r.ContentType ?? 'application/octet-stream',
lastModified: r.LastModified ?? new Date(0),
metadata: r.Metadata ?? {},
};
} catch (e) {
if (isNotFound(e)) {
throw new InterFileStorageObjectNotFoundError(
`Объект '${this.spec.name}/${key}' не найден`,
);
}
throw wrapBackendError(e, `head '${this.spec.name}/${key}'`);
}
}
private validateMetadata(metadata?: Readonly<Record<string, string>>): void {
if (!this.spec.metadataSchema) return;
for (const [field, kind] of Object.entries(this.spec.metadataSchema)) {
if (kind !== 'required') continue;
const value = metadata?.[field];
if (!value) {
throw new InterFileStorageMetadataValidationError(
`Метаданное '${field}' обязательно для бакета '${this.spec.name}'`,
);
}
}
}
}
/**
* Результат внутреннего fetch для HTTP-ручки `/api/storage/...`.
* Не часть публичного `InterFileStoragePort` — только для proxy-стрима внутри контроллера.
*/
export interface FileStorageObjectStream {
stream: NodeJS.ReadableStream;
size: number;
contentType: string;
lastModified: Date;
}
function validateSpec(spec: InterFileStorageBucketSpec): void {
if (!spec.name || !spec.name.includes(':')) {
throw new InterFileStorageBucketNotConfiguredError(
`BucketSpec.name должен иметь формат '<extension>:<purpose>', получено '${spec.name}'`,
);
}
if (!Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {
throw new InterFileStorageBucketNotConfiguredError(
`BucketSpec.maxBytes должно быть положительным конечным числом, получено ${spec.maxBytes}`,
);
}
if (!spec.allowedMime || spec.allowedMime.length === 0) {
throw new InterFileStorageBucketNotConfiguredError(
`BucketSpec.allowedMime должен содержать хотя бы один MIME-тип`,
);
}
}
async function materializeAndCheckSize(
body: InterFileStorageBody,
maxBytes: number,
specName: string,
): Promise<Buffer> {
if (body instanceof Uint8Array) {
if (body.byteLength > maxBytes) {
throw new InterFileStorageObjectTooLargeError(
`Размер ${body.byteLength} байт превышает лимит ${maxBytes} бакета '${specName}'`,
);
}
return Buffer.from(body);
}
// Web ReadableStream<Uint8Array> — duck-typed, чтобы не зависеть от глобального типа `ReadableStream`.
if (
typeof body === 'object' &&
body !== null &&
'getReader' in body &&
typeof (body as { getReader: unknown }).getReader === 'function'
) {
const reader = (body as { getReader: () => StreamReader }).getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
if (value && value.byteLength > 0) {
total += value.byteLength;
if (total > maxBytes) {
// Освобождаем reader, чтобы не оставлять стрим висящим.
try {
await reader.cancel();
} catch {
// ignore
}
throw new InterFileStorageObjectTooLargeError(
`Размер тела превышает лимит ${maxBytes} бакета '${specName}'`,
);
}
chunks.push(value);
}
}
return Buffer.concat(chunks);
}
throw new InterFileStorageBackendUnavailableError(
`Неподдерживаемый тип тела для бакета '${specName}'`,
);
}
interface StreamReader {
read(): Promise<{ value?: Uint8Array; done: boolean }>;
cancel(): Promise<void>;
}
function isNotFound(e: unknown): boolean {
if (typeof e !== 'object' || e === null) return false;
const ex = e as {
name?: string;
Code?: string;
$metadata?: { httpStatusCode?: number };
};
if (ex.name === 'NotFound' || ex.name === 'NoSuchKey' || ex.name === 'NoSuchBucket') return true;
if (ex.Code === 'NotFound' || ex.Code === 'NoSuchKey' || ex.Code === 'NoSuchBucket') return true;
if (ex.$metadata?.httpStatusCode === 404) return true;
return false;
}
function wrapBackendError(e: unknown, op: string): InterFileStorageBackendUnavailableError {
return new InterFileStorageBackendUnavailableError(`${op}: ${getMessage(e)}`, {
cause: asError(e),
});
}
function getMessage(e: unknown): string {
if (e instanceof Error) return e.message;
if (typeof e === 'string') return e;
return 'unknown error';
}
function asError(e: unknown): Error | undefined {
return e instanceof Error ? e : undefined;
}
function stripQuotes(s: string | undefined): string | undefined {
if (!s) return s;
return s.replace(/^"|"$/g, '');
}
function encodeKeyForPath(key: string): string {
// '/' — разделитель сегментов URL, остальное per-segment encodeURIComponent.
return key.split('/').map(encodeURIComponent).join('/');
}
@@ -0,0 +1,52 @@
import { signReadUrl, verifyReadUrl } from './signing';
describe('signing', () => {
const secret = 'test-secret-32-bytes-aaaaaaaaaaaa';
it('подпись детерминирована для одинакового набора параметров', () => {
const a = signReadUrl({ bucket: 'b1', key: 'k1', expUnix: 1000, secret });
const b = signReadUrl({ bucket: 'b1', key: 'k1', expUnix: 1000, secret });
expect(a).toBe(b);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
it('verify проходит на корректную подпись', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, sig, secret })).toBe(true);
});
it('verify отклоняет подмену bucket', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'expenses-receipts', key: 'a/b.jpg', expUnix: 9999, sig, secret })).toBe(false);
});
it('verify отклоняет подмену key', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/c.jpg', expUnix: 9999, sig, secret })).toBe(false);
});
it('verify отклоняет подмену exp', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 10000, sig, secret })).toBe(false);
});
it('verify отклоняет подмену секрета', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(
verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, sig, secret: 'other' }),
).toBe(false);
});
it('verify отклоняет подпись неверной длины без выброса исключения', () => {
expect(
verifyReadUrl({ bucket: 'b', key: 'k', expUnix: 1, sig: 'abcd', secret }),
).toBe(false);
});
it('verify отклоняет не-hex без выброса исключения', () => {
const fake = 'z'.repeat(64);
expect(
verifyReadUrl({ bucket: 'b', key: 'k', expUnix: 1, sig: fake, secret }),
).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { createHmac, timingSafeEqual } from 'crypto';
/**
* HMAC-SHA256(secret, "<bucket>\n<key>\n<exp>") в hex.
* Используется и адаптером (выдача URL), и HTTP-ручкой (валидация).
*/
export function signReadUrl(args: {
bucket: string;
key: string;
expUnix: number;
secret: string;
}): string {
const h = createHmac('sha256', args.secret);
h.update(`${args.bucket}\n${args.key}\n${args.expUnix}`);
return h.digest('hex');
}
/**
* Constant-time сверка подписи. Возвращает false на любую структурную ошибку
* (несовпадение длин, не-hex), а не выбрасывает — чтобы не различать каналы side-channel-ом.
*/
export function verifyReadUrl(args: {
bucket: string;
key: string;
expUnix: number;
sig: string;
secret: string;
}): boolean {
const expected = signReadUrl(args);
if (expected.length !== args.sig.length) return false;
let expectedBuf: Buffer;
let actualBuf: Buffer;
try {
expectedBuf = Buffer.from(expected, 'hex');
actualBuf = Buffer.from(args.sig, 'hex');
} catch {
return false;
}
if (expectedBuf.length !== actualBuf.length) return false;
return timingSafeEqual(expectedBuf, actualBuf);
}
@@ -0,0 +1,26 @@
import 'reflect-metadata';
import { BucketRegistry } from './bucket-registry';
import { UseBucket, bucketTokenFor } from './use-bucket.decorator';
describe('UseBucket decorator', () => {
beforeEach(() => {
BucketRegistry._resetForTests();
});
it('регистрирует спеку класса в BucketRegistry', () => {
@UseBucket({ name: 'demo:images', maxBytes: 1024, allowedMime: ['image/png'] })
class DemoService {}
const got = BucketRegistry.get(DemoService);
expect(got).toBeDefined();
expect(got?.name).toBe('demo:images');
expect(got?.maxBytes).toBe(1024);
expect(got?.allowedMime).toEqual(['image/png']);
});
it('bucketTokenFor стабилен и зависит только от имени класса', () => {
class FooService {}
expect(bucketTokenFor(FooService)).toBe('__InterFileStorageBucket:FooService');
expect(bucketTokenFor(FooService)).toBe(bucketTokenFor(FooService));
});
});
@@ -0,0 +1,42 @@
import { Inject } from '@nestjs/common';
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
/**
* DI-токен per-class бакета, под которым `forFeature` регистрирует фабрику `BucketHandle`.
* Конструктор `@InjectBucket()` инжектится по этому токену.
*/
export function bucketTokenFor(cls: { name: string }): string {
return `__InterFileStorageBucket:${cls.name}`;
}
/**
* Класс-декоратор: декларирует бакет, в который пишет данный сервис.
* Конвенция — один бакет на один сервис; повторная регистрация под другим именем запрещена.
*
* @example
* ```ts
* @UseBucket({ name: 'stol-zakazov:images', maxBytes: 10*MB, allowedMime: ['image/jpeg'] })
* @Injectable()
* export class OrderImagesService {
* constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
* }
* ```
*/
export function UseBucket(spec: InterFileStorageBucketSpec): ClassDecorator {
return (target) => {
BucketRegistry.add(target as unknown as { name: string }, spec);
};
}
/**
* Параметр-декоратор: инжектит `InterFileStorageBucket` для текущего класса.
* Резолвится в DI-токен `bucketTokenFor(<ClassName>)`, который провайдится через
* `FileStorageInfrastructureModule.forFeature([ThisClass])`.
*/
export function InjectBucket(): ParameterDecorator {
return (target, propertyKey, parameterIndex) => {
const cls = target as unknown as { name: string };
Inject(bucketTokenFor(cls))(target, propertyKey, parameterIndex);
};
}
@@ -0,0 +1,30 @@
# File-storage integration tests
Тесты против реального MinIO. По умолчанию ожидают MinIO на `http://localhost:9100` с
кредами `testuser` / `testpassword` (см. `docker-compose.yml` рядом).
## Как запустить
```bash
# 1. Поднять MinIO
docker compose -f components/controller/tests/file-storage/docker-compose.yml up -d
# дождаться healthy
# 2. Запустить интеграционные тесты
cd components/controller
npm run test:integration:file-storage
# 3. После работы
docker compose -f components/controller/tests/file-storage/docker-compose.yml down -v
```
## Переопределение через env
| Переменная | Default | Что делает |
|---|---|---|
| `FILE_STORAGE_TEST_MINIO_ENDPOINT` | `http://localhost:9100` | URL MinIO |
| `FILE_STORAGE_TEST_MINIO_ACCESS_KEY` | `testuser` | Access key |
| `FILE_STORAGE_TEST_MINIO_SECRET_KEY` | `testpassword` | Secret key |
| `FILE_STORAGE_TEST_BUCKET` | `coop-test-{timestamp}` | Имя физического бакета (уникально per-run) |
Если MinIO недоступен на `endpoint`, suite пропускает тесты (`it.skip`) с диагностическим сообщением.
@@ -0,0 +1,19 @@
version: '3'
services:
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: file-storage-test-minio
command: server /data --console-address ":9101"
environment:
MINIO_ROOT_USER: testuser
MINIO_ROOT_PASSWORD: testpassword
ports:
# Уникальные порты, чтобы не конфликтовать с prod-MinIO (9000) и другими инстансами.
- '9100:9000'
- '9101:9101'
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
interval: 5s
timeout: 3s
retries: 10
@@ -0,0 +1,280 @@
import 'reflect-metadata';
import { execSync } from 'child_process';
import { Readable } from 'stream';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import {
InterFileStorageMetadataValidationError,
InterFileStorageMimeRejectedError,
InterFileStorageObjectNotFoundError,
InterFileStorageObjectTooLargeError,
type InterFileStorageBucketSpec,
} from '@coopenomics/inter';
import {
FileStorageHttpController,
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
MinioFileStorageAdapter,
signReadUrl,
} from '../../src/infrastructure/file-storage';
const ENDPOINT = process.env.FILE_STORAGE_TEST_MINIO_ENDPOINT ?? 'http://localhost:9100';
const ACCESS_KEY = process.env.FILE_STORAGE_TEST_MINIO_ACCESS_KEY ?? 'testuser';
const SECRET_KEY = process.env.FILE_STORAGE_TEST_MINIO_SECRET_KEY ?? 'testpassword';
const BUCKET = process.env.FILE_STORAGE_TEST_BUCKET ?? `coop-test-${Date.now()}`;
const SIGNING_SECRET = 'integration-signing-secret-aaaaaaaaaaaaaaaa';
const PUBLIC_BASE_URL = 'https://test.example.org';
const SPEC: InterFileStorageBucketSpec = {
name: 'orders:images',
maxBytes: 10 * 1024,
allowedMime: ['image/jpeg', 'image/png', 'application/pdf'],
metadataSchema: {
ownerId: 'required',
altText: 'optional',
},
defaultUrlTtlSeconds: 60,
};
/** Sync-проверка доступности MinIO. Решение `it` vs `it.skip` принимается до describe(). */
function probeMinioSync(): boolean {
try {
execSync(`curl -fsS --max-time 2 ${ENDPOINT}/minio/health/live`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
const minioReachable = probeMinioSync();
if (!minioReachable) {
// eslint-disable-next-line no-console
console.warn(
`[integration] MinIO недоступен на ${ENDPOINT} — тесты пропущены. ` +
`Подними: docker compose -f tests/file-storage/docker-compose.yml up -d`,
);
}
const itLive = (name: string, fn: jest.ProvidesCallback, timeout?: number) =>
(minioReachable ? it : it.skip)(name, fn, timeout);
describe('file-storage integration (real MinIO)', () => {
let adapter: MinioFileStorageAdapter;
let app: INestApplication;
const opts: FileStorageInfrastructureOptions = {
endpoint: ENDPOINT,
accessKey: ACCESS_KEY,
secretKey: SECRET_KEY,
bucket: BUCKET,
signingSecret: SIGNING_SECRET,
publicBaseUrl: PUBLIC_BASE_URL,
forcePathStyle: true,
};
beforeAll(async () => {
if (!minioReachable) return;
adapter = new MinioFileStorageAdapter(opts);
await adapter.onApplicationBootstrap();
const moduleRef = await Test.createTestingModule({
controllers: [FileStorageHttpController],
providers: [
{ provide: FILE_STORAGE_OPTIONS, useValue: opts },
{ provide: MinioFileStorageAdapter, useValue: adapter },
],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
}, 60_000);
afterAll(async () => {
if (app) await app.close();
});
itLive('put → head → getReadUrl → GET → bytes равны (image)', async () => {
const bucket = adapter.getBucket(SPEC);
const payload = Buffer.from(jpegFixture());
await bucket.put('orders/42/main.jpg', payload, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u-1', altText: 'demo' },
});
const meta = await bucket.head('orders/42/main.jpg');
expect(meta.size).toBe(payload.length);
expect(meta.contentType).toBe('image/jpeg');
expect(meta.metadata.ownerid ?? meta.metadata.ownerId).toBe('u-1');
const url = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 30 });
const parsed = new URL(url);
const res = await request(app.getHttpServer())
.get(parsed.pathname)
.query({ exp: parsed.searchParams.get('exp')!, sig: parsed.searchParams.get('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.body as Buffer).equals(payload)).toBe(true);
}, 30_000);
itLive('put PDF среднего размера через стрим, читается обратно идентично', async () => {
const bucket = adapter.getBucket(SPEC);
const payload = Buffer.alloc(8 * 1024); // 8 KiB
for (let i = 0; i < payload.length; i++) payload[i] = i % 251;
const stream = bufferToWebStream(payload);
await bucket.put('reports/r-1/r1.pdf', stream, {
contentType: 'application/pdf',
metadata: { ownerId: 'u-2' },
});
const meta = await bucket.head('reports/r-1/r1.pdf');
expect(meta.size).toBe(payload.length);
const url = await bucket.getReadUrl('reports/r-1/r1.pdf', { ttlSeconds: 30 });
const parsed = new URL(url);
const res = await request(app.getHttpServer())
.get(parsed.pathname)
.query({ exp: parsed.searchParams.get('exp')!, sig: parsed.searchParams.get('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.body as Buffer).equals(payload)).toBe(true);
}, 30_000);
itLive('delete идемпотентен (повтор на отсутствующий — успех)', async () => {
const bucket = adapter.getBucket(SPEC);
await bucket.put('to-delete.jpg', Buffer.from([1, 2, 3]), {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
});
await bucket.delete('to-delete.jpg');
await expect(bucket.delete('to-delete.jpg')).resolves.toBeUndefined();
await expect(bucket.delete('never-existed.jpg')).resolves.toBeUndefined();
});
itLive('head отсутствующего → ObjectNotFoundError', async () => {
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('does-not-exist.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
itLive('превышение maxBytes → ObjectTooLargeError, объект не появился', async () => {
const bucket = adapter.getBucket(SPEC);
const tooBig = Buffer.alloc(SPEC.maxBytes + 1);
await expect(
bucket.put('too-big.jpg', tooBig, { contentType: 'image/jpeg', metadata: { ownerId: 'u' } }),
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
await expect(bucket.head('too-big.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
itLive('запрещённый MIME → MimeRejectedError, объект не появился', async () => {
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('bad.exe', Buffer.from([0]), {
contentType: 'application/octet-stream',
metadata: { ownerId: 'u' },
}),
).rejects.toBeInstanceOf(InterFileStorageMimeRejectedError);
await expect(bucket.head('bad.exe')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
itLive('отсутствие required-метаданного → MetadataValidationError', async () => {
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('no-meta.jpg', Buffer.from([0]), { contentType: 'image/jpeg', metadata: {} }),
).rejects.toBeInstanceOf(InterFileStorageMetadataValidationError);
});
itLive('HMAC-роут: 200 → 403 (exp) → 403 (sig) → 404', async () => {
const bucket = adapter.getBucket(SPEC);
const payload = Buffer.from('end-to-end');
await bucket.put('hmac-test/x.jpg', payload, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
});
// 200
const okUrl = await bucket.getReadUrl('hmac-test/x.jpg', { ttlSeconds: 30 });
const okParsed = new URL(okUrl);
const ok = await request(app.getHttpServer())
.get(okParsed.pathname)
.query({ exp: okParsed.searchParams.get('exp')!, sig: okParsed.searchParams.get('sig')! });
expect(ok.status).toBe(200);
// 403 — истёкший exp
const pastExp = Math.floor(Date.now() / 1000) - 1;
const pastSig = signReadUrl({
bucket: 'orders-images',
key: 'hmac-test/x.jpg',
expUnix: pastExp,
secret: SIGNING_SECRET,
});
const expired = await request(app.getHttpServer())
.get('/api/storage/orders-images/hmac-test/x.jpg')
.query({ exp: pastExp, sig: pastSig });
expect(expired.status).toBe(403);
// 403 — чужой sig
const futureExp = Math.floor(Date.now() / 1000) + 60;
const wrongSig = await request(app.getHttpServer())
.get('/api/storage/orders-images/hmac-test/x.jpg')
.query({ exp: futureExp, sig: 'a'.repeat(64) });
expect(wrongSig.status).toBe(403);
// 404 — несуществующий ключ, но валидная подпись
const missingKey = 'hmac-test/missing.jpg';
const missingSig = signReadUrl({
bucket: 'orders-images',
key: missingKey,
expUnix: futureExp,
secret: SIGNING_SECRET,
});
const notFound = await request(app.getHttpServer())
.get(`/api/storage/orders-images/${missingKey}`)
.query({ exp: futureExp, sig: missingSig });
expect(notFound.status).toBe(404);
}, 30_000);
});
// ─── helpers ─────────────────────────────────────────────────────────────────
function jpegFixture(): Uint8Array {
// Минимальный валидный JPEG header + EOI; для проверки трактовать как опаковые байты.
return new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0xff, 0xd9]);
}
function bufferToWebStream(buf: Buffer): ReadableStream<Uint8Array> {
// Простая реализация без зависимости от node:stream/web.
let sent = false;
return {
getReader() {
return {
async read() {
if (sent) return { value: undefined, done: true };
sent = true;
return { value: new Uint8Array(buf), done: false };
},
async cancel() {
/* noop */
},
};
},
} as unknown as ReadableStream<Uint8Array>;
}
// suppress unused-import warning for Readable in case parser flags
void Readable;
@@ -16,7 +16,6 @@
*/
import { Cooperative } from 'cooptypes';
import { AccountType } from '~/application/account/enum/account-type.enum';
import { registerCapitalInAgreementRegistry } from '~/extensions/capital/application/registration/register-capital-in-agreement-registry';
const baseConfig = {
@@ -89,9 +88,11 @@ describe('registerCapitalInAgreementRegistry', () => {
expect.objectContaining({
id: 'blagorost_offer',
registry_id: Cooperative.Registry.BlagorostOffer.registry_id,
agreement_type: 'blagorost',
agreement_type: 'capital',
extension_name: 'capital',
applicable_account_types: [AccountType.individual],
// Пусто — оферта подтягивается через программу CAPITALIZATION,
// не как дефолтная для individual (см. 8847a5c0939).
applicable_account_types: [],
})
);
+8 -18
View File
@@ -460,6 +460,9 @@ export const AllTypesProps: Record<string,any> = {
},
GenerationContractSignedMetaDocumentInput:{
},
GenerationConvertStatementGenerateDocumentInput:{
},
GenerationMoneyInvestStatementGenerateDocumentInput:{
@@ -470,9 +473,6 @@ export const AllTypesProps: Record<string,any> = {
},
GenerationMoneyInvestStatementSignedMetaDocumentInput:{
},
GenerationToMainWalletConvertStatementGenerateDocumentInput:{
},
GetAccountInput:{
@@ -770,6 +770,10 @@ export const AllTypesProps: Record<string,any> = {
data:"GenerationContractGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationConvertStatement:{
data:"GenerationConvertStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationMoneyInvestStatement:{
data:"GenerationMoneyInvestStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
@@ -786,18 +790,6 @@ export const AllTypesProps: Record<string,any> = {
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationToCapitalizationConvertStatement:{
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationToMainWalletConvertStatement:{
data:"GenerationToMainWalletConvertStatementGenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGenerationToProjectConvertStatement:{
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
},
capitalGenerateGetLoanDecision:{
data:"GenerateDocumentInput",
options:"GenerateDocumentOptionsInput"
@@ -3526,13 +3518,11 @@ export const ReturnTypes: Record<string,any> = {
capitalGenerateExpenseDecision:"GeneratedDocument",
capitalGenerateExpenseStatement:"GeneratedDocument",
capitalGenerateGenerationContract:"GeneratedDocument",
capitalGenerateGenerationConvertStatement:"GeneratedDocument",
capitalGenerateGenerationMoneyInvestStatement:"GeneratedDocument",
capitalGenerateGenerationPropertyInvestAct:"GeneratedDocument",
capitalGenerateGenerationPropertyInvestDecision:"GeneratedDocument",
capitalGenerateGenerationPropertyInvestStatement:"GeneratedDocument",
capitalGenerateGenerationToCapitalizationConvertStatement:"GeneratedDocument",
capitalGenerateGenerationToMainWalletConvertStatement:"GeneratedDocument",
capitalGenerateGenerationToProjectConvertStatement:"GeneratedDocument",
capitalGenerateGetLoanDecision:"GeneratedDocument",
capitalGenerateGetLoanStatement:"GeneratedDocument",
capitalGenerateProgramMoneyInvestStatement:"GeneratedDocument",
+143 -171
View File
@@ -5521,6 +5521,38 @@ export type ValueTypes = {
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version: string | Variable<any, string>
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string | Variable<any, string>,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null | Variable<any, string>,
/** Название кооператива, связанное с документом */
coopname: string | Variable<any, string>,
/** Дата и время создания документа */
created_at?: string | undefined | null | Variable<any, string>,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null | Variable<any, string>,
/** Язык документа */
lang?: string | undefined | null | Variable<any, string>,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null | Variable<any, string>,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string | Variable<any, string>,
/** Хэш проекта */
project_hash: string | Variable<any, string>,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null | Variable<any, string>,
/** Название документа */
title?: string | undefined | null | Variable<any, string>,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean | Variable<any, string>,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean | Variable<any, string>,
/** Имя пользователя, создавшего документ */
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null | Variable<any, string>
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -5597,40 +5629,6 @@ export type ValueTypes = {
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version: string | Variable<any, string>
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string | Variable<any, string>,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string | Variable<any, string>,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null | Variable<any, string>,
/** Название кооператива, связанное с документом */
coopname: string | Variable<any, string>,
/** Дата и время создания документа */
created_at?: string | undefined | null | Variable<any, string>,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null | Variable<any, string>,
/** Язык документа */
lang?: string | undefined | null | Variable<any, string>,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null | Variable<any, string>,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string | Variable<any, string>,
/** Хэш проекта */
project_hash: string | Variable<any, string>,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null | Variable<any, string>,
/** Название документа */
title?: string | undefined | null | Variable<any, string>,
/** Перевод на благорост кошелек */
to_blagorost: boolean | Variable<any, string>,
/** Перевод на основной кошелек */
to_wallet: boolean | Variable<any, string>,
/** Имя пользователя, создавшего документ */
username: string | Variable<any, string>,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null | Variable<any, string>
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -6421,13 +6419,11 @@ capitalGenerateComponentGenerationContract?: [{ data: ValueTypes["ComponentGener
capitalGenerateExpenseDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateExpenseStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationContract?: [{ data: ValueTypes["GenerationContractGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationConvertStatement?: [{ data: ValueTypes["GenerationConvertStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationMoneyInvestStatement?: [{ data: ValueTypes["GenerationMoneyInvestStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestAct?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationToCapitalizationConvertStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationToMainWalletConvertStatement?: [{ data: ValueTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGenerationToProjectConvertStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGetLoanDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateGetLoanStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
capitalGenerateProgramMoneyInvestStatement?: [{ data: ValueTypes["ProgramCapitalizationMoneyInvestStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
@@ -13829,6 +13825,38 @@ export type ResolverInputTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -13905,40 +13933,6 @@ export type ResolverInputTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Перевод на благорост кошелек */
to_blagorost: boolean,
/** Перевод на основной кошелек */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -14706,13 +14700,11 @@ capitalGenerateComponentGenerationContract?: [{ data: ResolverInputTypes["Compon
capitalGenerateExpenseDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateExpenseStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationContract?: [{ data: ResolverInputTypes["GenerationContractGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationConvertStatement?: [{ data: ResolverInputTypes["GenerationConvertStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationMoneyInvestStatement?: [{ data: ResolverInputTypes["GenerationMoneyInvestStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestAct?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationPropertyInvestStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationToCapitalizationConvertStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationToMainWalletConvertStatement?: [{ data: ResolverInputTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGenerationToProjectConvertStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGetLoanDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateGetLoanStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
capitalGenerateProgramMoneyInvestStatement?: [{ data: ResolverInputTypes["ProgramCapitalizationMoneyInvestStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
@@ -18965,13 +18957,13 @@ export type ModelTypes = {
/** Обогащенные данные коммита (diff-патч, исходная ссылка и т.д.) */
data?: ModelTypes["JSON"] | undefined | null,
/** Описание коммита */
description: string,
description?: string | undefined | null,
/** Отображаемое имя пользователя */
display_name?: string | undefined | null,
/** ID в блокчейне */
id?: number | undefined | null,
/** Метаданные коммита */
meta: string,
meta?: string | undefined | null,
/** Флаг присутствия записи в блокчейне */
present: boolean,
/** Проект, к которому относится коммит */
@@ -21895,6 +21887,38 @@ export type ModelTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -21971,40 +21995,6 @@ export type ModelTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Перевод на благорост кошелек */
to_blagorost: boolean,
/** Перевод на основной кошелек */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -22869,6 +22859,10 @@ export type ModelTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationContract: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member. */
capitalGenerateGenerationConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление об инвестировании в генерацию
Требуемые роли: chairman, member. */
@@ -22885,18 +22879,6 @@ export type ModelTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationPropertyInvestStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в благорост
Требуемые роли: chairman, member. */
capitalGenerateGenerationToCapitalizationConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в основной кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToMainWalletConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в проектный кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToProjectConvertStatement: ModelTypes["GeneratedDocument"],
/** Сгенерировать решение о получении займа
Требуемые роли: chairman, member. */
@@ -27626,13 +27608,13 @@ export type GraphQLTypes = {
/** Обогащенные данные коммита (diff-патч, исходная ссылка и т.д.) */
data?: GraphQLTypes["JSON"] | undefined | null,
/** Описание коммита */
description: string,
description?: string | undefined | null,
/** Отображаемое имя пользователя */
display_name?: string | undefined | null,
/** ID в блокчейне */
id?: number | undefined | null,
/** Метаданные коммита */
meta: string,
meta?: string | undefined | null,
/** Флаг присутствия записи в блокчейне */
present: boolean,
/** Проект, к которому относится коммит */
@@ -30712,6 +30694,38 @@ export type GraphQLTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationConvertStatementGenerateDocumentInput"]: {
/** Сумма для перевода в программу «Благорост» */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода в Цифровой Кошелёк */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Признак перевода в программу «Благорост» */
to_blagorost: boolean,
/** Признак перевода в Цифровой Кошелёк */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
/** Сумма инвестирования */
@@ -30788,40 +30802,6 @@ export type GraphQLTypes = {
username: string,
/** Версия генератора, использованного для создания документа */
version: string
};
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
/** Хэш приложения */
appendix_hash: string,
/** Сумма для перевода на благорост кошелек */
blagorost_wallet_amount: string,
/** Номер блока, на котором был создан документ */
block_num?: number | undefined | null,
/** Название кооператива, связанное с документом */
coopname: string,
/** Дата и время создания документа */
created_at?: string | undefined | null,
/** Имя генератора, использованного для создания документа */
generator?: string | undefined | null,
/** Язык документа */
lang?: string | undefined | null,
/** Ссылки, связанные с документом */
links?: Array<string> | undefined | null,
/** Сумма для перевода на основной кошелек */
main_wallet_amount: string,
/** Хэш проекта */
project_hash: string,
/** Часовой пояс, в котором был создан документ */
timezone?: string | undefined | null,
/** Название документа */
title?: string | undefined | null,
/** Перевод на благорост кошелек */
to_blagorost: boolean,
/** Перевод на основной кошелек */
to_wallet: boolean,
/** Имя пользователя, создавшего документ */
username: string,
/** Версия генератора, использованного для создания документа */
version?: string | undefined | null
};
["GetAccountInput"]: {
/** Имя аккаунта пользователя */
@@ -31739,6 +31719,10 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationContract: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
Требуемые роли: chairman, member. */
capitalGenerateGenerationConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление об инвестировании в генерацию
Требуемые роли: chairman, member. */
@@ -31755,18 +31739,6 @@ export type GraphQLTypes = {
Требуемые роли: chairman, member. */
capitalGenerateGenerationPropertyInvestStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в благорост
Требуемые роли: chairman, member. */
capitalGenerateGenerationToCapitalizationConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в основной кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToMainWalletConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать заявление о конвертации из генерации в проектный кошелек
Требуемые роли: chairman, member. */
capitalGenerateGenerationToProjectConvertStatement: GraphQLTypes["GeneratedDocument"],
/** Сгенерировать решение о получении займа
Требуемые роли: chairman, member. */
@@ -35917,10 +35889,10 @@ type ZEUS_VARIABLES = {
["GenerationContractGenerateDocumentInput"]: ValueTypes["GenerationContractGenerateDocumentInput"];
["GenerationContractSignedDocumentInput"]: ValueTypes["GenerationContractSignedDocumentInput"];
["GenerationContractSignedMetaDocumentInput"]: ValueTypes["GenerationContractSignedMetaDocumentInput"];
["GenerationConvertStatementGenerateDocumentInput"]: ValueTypes["GenerationConvertStatementGenerateDocumentInput"];
["GenerationMoneyInvestStatementGenerateDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementGenerateDocumentInput"];
["GenerationMoneyInvestStatementSignedDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementSignedDocumentInput"];
["GenerationMoneyInvestStatementSignedMetaDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementSignedMetaDocumentInput"];
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: ValueTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"];
["GetAccountInput"]: ValueTypes["GetAccountInput"];
["GetAccountsInput"]: ValueTypes["GetAccountsInput"];
["GetBranchesInput"]: ValueTypes["GetBranchesInput"];
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cooptypes",
"type": "module",
"version": "2026.5.11-4",
"version": "2026.5.14-2",
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
"license": "MIT",
@@ -34,8 +34,8 @@ export interface Model {
to_blagorost: boolean
}
export const title = 'Заявление о переводе части целевого паевого взноса'
export const description = 'Форма заявления о переводе части целевого паевого взноса'
export const title = 'Заявление о трансляции паевого взноса из программы "Генерация"'
export const description = 'Форма заявления о трансляции паевого взноса из программы "Генерация" в Цифровой Кошелёк и/или в программу «Благорост»'
export const context = `<div class="digital-document"><div style="text-align: right"><p>{% trans 'to_council' %} {{ vars.full_abbr_genitive }} "{{ vars.name }}"</p><p>{% trans 'from_shareholder' %} {{ common_user.full_name_or_short_name }}</p></div><div style="text-align: center"><h2>{% trans 'statement_title' %}</h2><h3>{% trans 'statement_subtitle' %}</h3></div><p>{% trans 'statement_text' %} № {{ contributor_contract_number }} {% trans 'from_date' %} {{ contributor_contract_created_at }} {% trans 'and_appendix' %} № {{ appendix_short_hash }}, {% trans 'request_text' %} №{{ project_short_hash }} {% trans 'following_way' %}:</p><ol>{% if to_wallet %}<li>{% trans 'wallet_conversion_text', main_wallet_amount %}</li>{% endif %}{% if to_blagorost %}<li>{% trans 'blagorost_conversion_text', blagorost_wallet_amount %}</li>{% endif %}</ol><div style="margin-top: 40px;"><p>{% trans 'signed_by_digital_signature' %}</p><p style="text-align: right">{{ meta.created_at }}</p></div></div><style>.digital-document {padding: 20px;white-space: pre-wrap;}</style>`
@@ -1,22 +0,0 @@
import type { IGenerate, IMetaDocument } from '../../document'
export const registry_id = 1081
// Модель действия для генерации
export interface Action extends IGenerate {
registry_id: number
}
export type Meta = IMetaDocument & Action
// Модель данных документа
export interface Model {
meta: IMetaDocument
}
export const title = 'Заявление о конвертации средств генерации в проект'
export const description = 'Форма заявления о конвертации средств из генерации в конкретный проект'
export const context = '<div class="digital-document"><div style="text-align: center"><h2>ЗАЯВЛЕНИЕ О КОНВЕРТАЦИИ СРЕДСТВ ГЕНЕРАЦИИ В ПРОЕКТ</h2></div><p>Прошу конвертировать средства из генерации для инвестирования в проект.</p><p>Подпись: Иван Иванович</p></div>'
export const translations = {}
export const exampleData = {}
@@ -1,22 +0,0 @@
import type { IGenerate, IMetaDocument } from '../../document'
export const registry_id = 1082
// Модель действия для генерации
export interface Action extends IGenerate {
registry_id: number
}
export type Meta = IMetaDocument & Action
// Модель данных документа
export interface Model {
meta: IMetaDocument
}
export const title = 'Заявление о конвертации средств генерации в Благорост'
export const description = 'Форма заявления о конвертации средств из генерации в программу Благорост'
export const context = '<div class="digital-document"><div style="text-align: center"><h2>ЗАЯВЛЕНИЕ О КОНВЕРТАЦИИ СРЕДСТВ ГЕНЕРАЦИИ В благорост</h2></div><p>Прошу конвертировать средства из генерации для участия в программе благороста.</p><p>Подпись: Иван Иванович</p></div>'
export const translations = {}
export const exampleData = {}
@@ -61,9 +61,7 @@ export * as CapitalizationPropertyInvestStatement from './1070.CapitalizationPro
export * as CapitalizationPropertyInvestDecision from './1071.CapitalizationPropertyInvestDecision'
export * as CapitalizationPropertyInvestAct from './1072.CapitalizationPropertyInvestAct'
export * as GenerationToMainWalletConvertStatement from './1080.GenerationToMainWalletConvertStatement'
export * as GenerationToProjectConvertStatement from './1081.GenerationToProjectConvertStatement'
export * as GenerationToCapitalizationConvertStatement from './1082.GenerationToCapitalizationConvertStatement'
export * as GenerationConvertStatement from './1080.GenerationConvertStatement'
export * as CapitalizationToMainWalletConvertStatement from './1090.CapitalizationToMainWalletConvertStatement'
@@ -24,12 +24,13 @@ export const LEDGER2_WALLET_REGISTRY: readonly WalletMeta[] = [
{ name: "w.wal.member", human_name: "ЦК — членская часть пайщика", kind: "USER_SHARED" },
{ name: "w.cap.blago", human_name: "ЦПП «Благорост» — единый кошелёк программы у пайщика", kind: "USER_SHARED" },
{ name: "w.cap.gen", human_name: "ЦПП «Генератор» — единый кошелёк программы у пайщика", kind: "USER_SHARED" },
{ name: "w.cap.preimp", human_name: "Первичный учёт РИД-взносов до подписания договора УХД", kind: "USER_SHARED" },
{ name: "w.cap.preimp", human_name: "Первичный учёт РИД-взносов до перехода на электронный учёт", kind: "USER_SHARED" },
{ name: "w.reg.entry", human_name: "Вступительные взносы", kind: "COOPERATIVE" },
{ name: "w.wal.wthdrw", human_name: "Возвраты паевых взносов пайщикам", kind: "COOPERATIVE" },
{ name: "w.sov.infra", human_name: "Членские взносы за инфраструктуру кооп. платформы", kind: "COOPERATIVE" },
{ name: "w.sov.delgte", human_name: "Делегатские членские взносы", kind: "COOPERATIVE" },
{ name: "w.sov.expns", human_name: "Хозяйственные расходы из числа целевого финансирования", kind: "COOPERATIVE" },
{ name: "w.sov.mnused", human_name: "Использованные минимальные паевые взносы", kind: "COOPERATIVE" },
{ name: "w.cap.loan", human_name: "Выданные пайщикам беспроцентные займы", kind: "COOPERATIVE" },
{ name: "w.mkt.payout", human_name: "Выплаты поставщикам", kind: "COOPERATIVE" },
] as const
@@ -27,6 +27,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_USER_SHARED_PRO
"required_program_id": 3,
"wallet_name": "w.cap.gen",
},
{
"program_label": null,
"required_program_id": 0,
"wallet_name": "w.cap.preimp",
},
]
`;
@@ -57,6 +62,11 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
"kind": "USER_SHARED",
"name": "w.cap.gen",
},
{
"human_name": "Первичный учёт РИД-взносов до перехода на электронный учёт",
"kind": "USER_SHARED",
"name": "w.cap.preimp",
},
{
"human_name": "Вступительные взносы",
"kind": "COOPERATIVE",
@@ -77,6 +87,16 @@ exports[`ledger2 wallets registry (generated from C++) > LEDGER2_WALLET_REGISTRY
"kind": "COOPERATIVE",
"name": "w.sov.delgte",
},
{
"human_name": "Хозяйственные расходы из числа целевого финансирования",
"kind": "COOPERATIVE",
"name": "w.sov.expns",
},
{
"human_name": "Использованные минимальные паевые взносы",
"kind": "COOPERATIVE",
"name": "w.sov.mnused",
},
{
"human_name": "Выданные пайщикам беспроцентные займы",
"kind": "COOPERATIVE",
@@ -47,7 +47,7 @@ q-btn(
.row.items-center.q-gutter-x-sm
span.text-caption.text-grey-7 Удовлетворение результатом
span.text-body2.text-weight-medium.text-accent
| {{ formData.satisfaction_stars }} / 5
| {{ satisfactionLabel }}
q-rating(
v-model='formData.satisfaction_stars',
:max='5',
@@ -124,7 +124,12 @@ const formData = ref({
creator_hours: 0,
description: '',
review_text: '',
satisfaction_stars: 5,
satisfaction_stars: 0,
});
const satisfactionLabel = computed(() => {
const stars = formData.value.satisfaction_stars;
return stars > 0 ? `${stars} / 5` : 'не указано';
});
const commitBreakdown = computed(() => {
@@ -161,7 +166,7 @@ const commitCostCaption = computed(() => {
watch(showDialog, (isOpen) => {
if (isOpen) {
formData.value.creator_hours = props.uncommittedHours || 0;
formData.value.satisfaction_stars = 5;
formData.value.satisfaction_stars = 0;
formData.value.review_text = '';
}
});
@@ -172,7 +177,7 @@ const clear = () => {
creator_hours: props.uncommittedHours || 0,
description: '',
review_text: '',
satisfaction_stars: 5,
satisfaction_stars: 0,
};
};
@@ -180,6 +185,10 @@ const handleCreateCommit = async () => {
try {
isSubmitting.value = true;
const reviewText = formData.value.review_text.trim();
const stars = formData.value.satisfaction_stars;
const hasFeedback = stars >= 1 || reviewText.length > 0;
const commitDataPayload: ICreateCommitInput = {
coopname: system.info.coopname,
commit_hours: formData.value.creator_hours,
@@ -187,15 +196,17 @@ const handleCreateCommit = async () => {
username: session.username || createCommitInput.value.username,
description: '',
meta: JSON.stringify({}),
data: [
{
type: 'contribution_feedback',
data: {
review_text: formData.value.review_text.trim(),
satisfaction_stars: formData.value.satisfaction_stars,
},
},
],
data: hasFeedback
? [
{
type: 'contribution_feedback',
data: {
review_text: reviewText,
satisfaction_stars: stars,
},
},
]
: [],
};
await createCommit(commitDataPayload);
@@ -5,12 +5,12 @@ import type {
import { client } from 'src/shared/api/client';
import { Mutations } from '@coopenomics/sdk';
async function generateGenerationToProjectConvertStatement(
data: Mutations.Capital.GenerateGenerationToProjectConvertStatement.IInput['data'],
async function generateGenerationConvertStatement(
data: Mutations.Capital.GenerateGenerationConvertStatement.IInput['data'],
options?: IGenerateDocumentOptionsInput,
): Promise<IGeneratedDocumentOutput> {
const { [Mutations.Capital.GenerateGenerationToProjectConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationToProjectConvertStatement.mutation, {
const { [Mutations.Capital.GenerateGenerationConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationConvertStatement.mutation, {
variables: {
data,
options,
@@ -21,5 +21,5 @@ async function generateGenerationToProjectConvertStatement(
}
export const api = {
generateGenerationToProjectConvertStatement,
generateGenerationConvertStatement,
};
@@ -0,0 +1,5 @@
import { api } from '../api';
export const generateGenerationConvertStatementModel = {
api,
};
@@ -1,25 +0,0 @@
import type {
IGenerateDocumentOptionsInput,
IGeneratedDocumentOutput,
} from 'src/shared/lib/types/document';
import { client } from 'src/shared/api/client';
import { Mutations } from '@coopenomics/sdk';
async function generateGenerationToCapitalizationConvertStatement(
data: Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.IInput['data'],
options?: IGenerateDocumentOptionsInput,
): Promise<IGeneratedDocumentOutput> {
const { [Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.mutation, {
variables: {
data,
options,
},
});
return result;
}
export const api = {
generateGenerationToCapitalizationConvertStatement,
};
@@ -1,5 +0,0 @@
import { api } from '../api';
export const generateGenerationToCapitalizationConvertStatementModel = {
api,
};
@@ -1,2 +0,0 @@
// UI компоненты для генерации заявления о конвертации из генерации в благорост
export {};
@@ -1,25 +0,0 @@
import type {
IGenerateDocumentOptionsInput,
IGeneratedDocumentOutput,
} from 'src/shared/lib/types/document';
import { client } from 'src/shared/api/client';
import { Mutations } from '@coopenomics/sdk';
async function generateGenerationToMainWalletConvertStatement(
data: Mutations.Capital.GenerateGenerationToMainWalletConvertStatement.IInput['data'],
options?: IGenerateDocumentOptionsInput,
): Promise<IGeneratedDocumentOutput> {
const { [Mutations.Capital.GenerateGenerationToMainWalletConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationToMainWalletConvertStatement.mutation, {
variables: {
data,
options,
},
});
return result;
}
export const api = {
generateGenerationToMainWalletConvertStatement,
};
@@ -1,3 +0,0 @@
export * as model from './model';
export * as api from './api';
export * as ui from './ui';
@@ -1,5 +0,0 @@
import { api } from '../api';
export const generateGenerationToMainWalletConvertStatementModel = {
api,
};
@@ -1,3 +0,0 @@
export * as model from './model';
export * as api from './api';
export * as ui from './ui';
@@ -1,5 +0,0 @@
import { api } from '../api';
export const generateGenerationToProjectConvertStatementModel = {
api,
};
@@ -1,2 +0,0 @@
// UI компоненты для генерации заявления о конвертации из генерации в проектный кошелек
export {};
@@ -1,5 +1,3 @@
// Фичи для работы с распределением средств
export * as GenerateGenerationToMainWalletConvertStatement from './GenerateGenerationToMainWalletConvertStatement';
export * as GenerateGenerationToProjectConvertStatement from './GenerateGenerationToProjectConvertStatement';
export * as GenerateGenerationToCapitalizationConvertStatement from './GenerateGenerationToCapitalizationConvertStatement';
export * as GenerateGenerationConvertStatement from './GenerateGenerationConvertStatement';
export * as GenerateCapitalizationToMainWalletConvertStatement from './GenerateCapitalizationToMainWalletConvertStatement';
@@ -7,9 +7,9 @@ export type IConvertSegmentOutput =
Mutations.Capital.ConvertSegment.IOutput[typeof Mutations.Capital.ConvertSegment.name];
export type IGenerateConvertStatementInput =
Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.IInput;
Mutations.Capital.GenerateGenerationConvertStatement.IInput;
export type IGenerateConvertStatementOutput =
Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.IOutput[typeof Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.name];
Mutations.Capital.GenerateGenerationConvertStatement.IOutput[typeof Mutations.Capital.GenerateGenerationConvertStatement.name];
async function convertSegment(
@@ -29,8 +29,8 @@ async function generateConvertStatement(
data: IGenerateConvertStatementInput['data'],
options?: IGenerateConvertStatementInput['options'],
): Promise<IGenerateConvertStatementOutput> {
const { [Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationToCapitalizationConvertStatement.mutation, {
const { [Mutations.Capital.GenerateGenerationConvertStatement.name]: result } =
await client.Mutation(Mutations.Capital.GenerateGenerationConvertStatement.mutation, {
variables: {
data,
options,
@@ -30,10 +30,17 @@ export function useConvertSegment() {
capital_amount: number;
}
) {
// 1. Генерируем документ заявления о конвертации
// 1. Генерируем документ заявления о конвертации.
// Шаблон 1080 — универсальный: одно заявление с двумя суммами и двумя флагами.
// appendix_hash подтягивается на бекенде по (username, project_hash).
const generatedDocument = await api.generateConvertStatement({
coopname: segmentData.coopname,
username: segmentData.username,
project_hash: segmentData.project_hash,
main_wallet_amount: formatToEosioAsset(segmentData.wallet_amount),
blagorost_wallet_amount: formatToEosioAsset(segmentData.capital_amount),
to_wallet: segmentData.wallet_amount > 0,
to_blagorost: segmentData.capital_amount > 0,
});
// 2. Подписываем документ конвертации
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/desktop",
"version": "2026.5.11-4",
"version": "2026.5.14-2",
"description": "Рабочий стол кооператива — Vue 3 + Quasar Framework",
"productName": "Desktop App",
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
+2
View File
@@ -4,3 +4,5 @@ shots/
# генерируются add-plain-participant при первом прогоне.
state/participants/*.json
!state/participants/.gitkeep
state/cooperatives/*.json
!state/cooperatives/.gitkeep
+114 -57
View File
@@ -39,8 +39,16 @@ export async function openBrowser({ storageState } = {}) {
const page = await context.newPage();
const consoleLog = [];
page.on('console', m => consoleLog.push(`[${m.type()}] ${m.text()}`));
page.on('pageerror', e => consoleLog.push(`[pageerror] ${e.message}`));
const debugConsole = process.env.DEBUG_CONSOLE === '1';
page.on('console', m => {
const line = `[${m.type()}] ${m.text()}`;
consoleLog.push(line);
if (debugConsole) console.log(' [browser]', line.slice(0, 300));
});
page.on('pageerror', e => {
consoleLog.push(`[pageerror] ${e.message}`);
if (debugConsole) console.log(' [browser pageerror]', e.message.slice(0, 300));
});
page.on('requestfailed', r => {
const u = r.url();
if (!u.includes('/src/') && !u.includes('/node_modules/') && !u.includes('/@vite')) {
@@ -74,69 +82,118 @@ export async function loginAs(page, fixture) {
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
}
// Закрывает каскад модалок-документов первого входа (Положение о ЦПП Кошелёк,
// ЭП, политика, пользовательское соглашение, и при наличии — Capital-документы).
// Кликает «Подписать» нативно через DOM, потому что Playwright actionability-check
// спотыкается о pointer-events на тексте.
// Скрывает каскад модалок-документов первого входа (Положение о ЦПП Кошелёк,
// ЭП, политика обработки ПД, пользовательское соглашение). После регистрации
// или первого логина в Восходе у chairman'а/пайщика рендерятся параллельно 4
// SignAgreementDialog (см. widgets/RequireAgreements) — каждый в своём
// q-portal--dialog--N. На каждом page.goto Vue их перерендерит заново.
//
// Главная сложность: между документами появляется промежуточный диалог
// «Формируем документ...» со спиннером — кнопки в нём ещё нет. Ждём пока
// «Подписать» появится, потом клик.
// На локальном тестовом стенде реальная подпись (signAgreement → push в
// блокчейн) не отрабатывает: submit-handler q-form вызывается, validate
// проходит, но signAgreement зависает либо backend отдаёт ошибку без
// видимого UX-feedback. Для целей docs-harness нам важно ПРОЙТИ дальше по
// сценарию, а не фиксировать сам онбординг.
//
// Стратегия: ставим MutationObserver, который при добавлении/изменении
// q-portal--dialog--N проверяет, не онбординг ли это («Прочитайте и подпишите
// документ» в заголовке) — и если да, ставит display:none + чистит body-флаги
// Quasar (q-body--prevent-scroll и пр.). Наблюдатель остаётся жить до конца
// page-сессии и работает после каждого Vue-перерендера.
export async function dismissOnboardingDialogs(page) {
// Стартовое окно — даём шанс модалкам появиться. Если их нет за 12 сек —
// значит онбординг уже пройден или политика обходит этого пользователя.
const firstAppeared = await page
.locator('[id^="q-portal--dialog--"]').first()
.waitFor({ state: 'visible', timeout: 12000 })
.then(() => true).catch(() => false);
if (!firstAppeared) return;
await page.evaluate(() => {
if (window.__onboardingDialogsBlocker) return;
// Цикл идёт пока в DOM есть видимый q-portal--dialog. На каждой итерации:
// 1. Ждём появления кнопки «Подписать» в самом верхнем диалоге (до 30 сек).
// 2. Кликаем нативно через DOM.
// 3. Ждём 3.5 сек на blockchain confirm + переход к следующему документу.
for (let i = 0; i < 20; i++) {
const hasDialog = await page.evaluate(() =>
Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.some((p) => getComputedStyle(p).display !== 'none'),
);
if (!hasDialog) break;
const isOnboarding = (el) => {
const title = el.querySelector('.q-toolbar__title, .modal-base__title, h1, h2, h3, h4')?.textContent || '';
if (/Прочитайте и подпишите/i.test(title)) return true;
const body = el.textContent || '';
return /Прочитайте и подпишите документ/i.test(body) && /Подписать/i.test(body);
};
// Ждём пока в верхнем диалоге появится активная кнопка «Подписать».
const btnReady = await page.waitForFunction(
() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return true; // диалогов нет — выходим
const top = portals[portals.length - 1];
return !!Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
},
{ timeout: 60000 },
).then(() => true).catch(() => false);
if (!btnReady) break;
// Quasar q-dialog рендерит контент через Teleport в q-portal--dialog--N.
// v-show / transition мехнизмы могут перезаписывать inline style.display
// даже с !important (Vue 3 v-show через el.style.display = ...). CSS-rule
// с !important работает, НО Quasar также пересоздаёт q-portal-узлы при
// mount/unmount. Самый надёжный путь — физически удалить узел: Vue не
// сможет восстановить контент, так как Teleport target исчез. Это
// вызовет warn в консоли, но визуально UI чист.
const hide = (el) => {
if (!el.parentNode) return;
el.remove();
};
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
const cleanupBody = () => {
document.body.classList.remove(
'q-body--prevent-scroll',
'q-body--has-fixed-dialog',
'q-body--has-dialog',
);
document.body.style.paddingRight = '';
};
const scan = () => {
let touched = 0;
const portals = document.querySelectorAll('[id^="q-portal--dialog--"]');
if (portals.length > 0 && !window.__onboardingScanLogged) {
window.__onboardingScanLogged = true;
portals.forEach((el) => {
console.log(`[onboarding-blocker] portal ${el.id} visible=${getComputedStyle(el).display !== 'none'} bodyLen=${el.textContent?.length} onboarding=${isOnboarding(el)} hidden=${el.dataset.onboardingHidden === '1'}`);
});
}
portals.forEach((el) => {
const onboarding = isOnboarding(el);
if (onboarding && el.dataset.onboardingHidden !== '1') {
hide(el);
touched++;
}
});
if (touched > 0) {
cleanupBody();
console.log(`[onboarding-blocker] hidden ${touched} of ${portals.length} portals`);
window.__onboardingScanLogged = false; // позволить новый дамп если новые портал-диалоги появятся
}
};
const observer = new MutationObserver((mutations) => {
// Не дёргаем scan на каждой мутации; делаем единый проход на любом
// добавлении узлов в body.
for (const m of mutations) {
if (m.addedNodes.length > 0 || m.type === 'characterData') {
scan();
break;
}
}
// Также: если body заново получил q-body--prevent-scroll, а все наши
// скрытые порталы остались в DOM — снимаем класс. Это случается при
// перерендере (Vue добавляет класс через q-dialog logic).
const hasHidden = document.querySelector('[id^="q-portal--dialog--"][data-onboarding-hidden="1"]');
if (hasHidden) cleanupBody();
});
if (!clicked) break;
await page.waitForTimeout(3500);
}
observer.observe(document.body, { childList: true, subtree: true });
// Финальная страховка: ждём чтобы все портал-диалоги ушли с экрана.
// Также — переодический scan на случай если MutationObserver промахнётся.
const interval = setInterval(scan, 1000);
window.__onboardingDialogsBlocker = { observer, interval };
scan();
});
// Дать MutationObserver'у время прожать первый scan и скрыть существующие диалоги.
await page.waitForTimeout(400);
// Активный wait: убедиться, что нет ни одного видимого онбординг-портала.
// Vue может смонтировать новые q-portal--dialog с задержкой 1-5 сек после
// navigation, и observer должен их поймать — здесь мы просто блокируем до
// отсутствия видимых онбординг-порталов на экране (до 12 сек).
await page.waitForFunction(
() => !Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.some((p) => getComputedStyle(p).display !== 'none'),
{ timeout: 30000 },
() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'));
const isOnboarding = (el) => {
const body = el.textContent || '';
return /Прочитайте и подпишите документ/i.test(body) && /Подписать/i.test(body);
};
return !portals.some((p) => isOnboarding(p) && getComputedStyle(p).display !== 'none');
},
{ timeout: 12_000, polling: 200 },
).catch(() => {});
}
@@ -0,0 +1,176 @@
// Хелперы регистрации в Восходе: повторно используется в сценариях 01-08
// onboarding'а (каждый последующий сценарий проходит весь wizard до своего
// «точки интереса»; перепроходить wizard в Playwright детерминированно проще,
// чем восстанавливать sign-up state из IndexedDB).
//
// Контракт фикстуры coopData — см. scenarios/onboarding/01-register-coop.mjs:
// short_name / full_name / type / represented_by / phone / country / city /
// full_address / fact_address / inn / ogrn / kpp / bank.{name,corr,bik,kpp,account,currency}.
// email генерится тут с уникальным RUN_ID, чтобы registerAccount не падал
// на дубликате email.
import fs from 'node:fs';
export function makeRunId() {
return String(Date.now()).slice(-6);
}
async function fillByLabel(page, label, value) {
await page.locator(`label:has-text("${label}")`).first().locator('input,textarea').fill(value);
}
async function clickContinue(page) {
await page.locator('button:has-text("Продолжить")').first().click();
}
/**
* Проходит wizard /auth/signup для organization-type кооператива до экрана
* ReadStatement. Делает шоты ключевых шагов через переданный `shot`. По
* пути перехватывает GraphQL ответ registerAccount, чтобы достать
* сгенерированный username и WIF из поля «Приватный ключ».
*
* @returns {Promise<{username: string|null, wif: string|null, email: string}>}
*/
export async function signUpAsOrganization({
page,
shot,
env,
coopData,
fixturePath,
runId,
}) {
const id = runId ?? makeRunId();
const email = `${coopData.emailLocal || 'chairman'}+${id}@example.com`;
// --- 1. Открываем форму регистрации. ---
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/auth/signup`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('text=ВСТУПИТЬ В ПАЙЩИКИ', { timeout: 30_000 });
await page.waitForTimeout(500);
await shot(page, '01-signup-empty', 'Открытая форма /auth/signup: первый шаг визарда — ввод электронной почты.');
// --- 2. Email. ---
await fillByLabel(page, 'Введите email', email);
await page.waitForTimeout(300);
await shot(page, '02-email-filled', `Введён email будущего председателя второго кооператива (${email}).`);
await clickContinue(page);
// --- 3. Тип аккаунта — Организация. ---
await page.locator('.q-stepper__tab--active:has-text("Заполните форму заявления")').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('button:has-text("Организация")').first()
.waitFor({ state: 'visible', timeout: 10_000 });
await page.waitForTimeout(400);
await shot(page, '03-type-buttons', 'Выбор типа пайщика: три кнопки — «Физлицо», «ИП», «Организация».');
await page.locator('button:has-text("Организация")').click();
await page.waitForSelector('label:has-text("Краткое наименование организации")', { timeout: 15_000 });
await page.waitForTimeout(400);
await shot(page, '04-organization-empty', 'Раскрылась форма данных организации (после клика «Организация»).');
// --- 4. Заполнение данных кооператива. ---
await fillByLabel(page, 'Краткое наименование организации', coopData.short_name);
await fillByLabel(page, 'Полное наименование организации', coopData.full_name);
await page.locator('label:has-text("Выберите тип организации")').first().click();
await page.waitForSelector(`text=${coopData.type}`, { timeout: 5_000 });
await page.locator(`.q-menu .q-item:has-text("${coopData.type}")`).first().click();
await page.waitForTimeout(300);
await fillByLabel(page, 'Фамилия представителя', coopData.represented_by.last_name);
await fillByLabel(page, 'Имя представителя', coopData.represented_by.first_name);
await fillByLabel(page, 'Отчество представителя', coopData.represented_by.middle_name);
await fillByLabel(page, 'действует на основании', coopData.represented_by.based_on);
await fillByLabel(page, 'Должность представителя', coopData.represented_by.position);
await fillByLabel(page, 'Номер телефона представителя', coopData.phone);
await page.locator('label:has-text("Страна")').first().click();
await page.locator('.q-menu .q-item:has-text("Россия")').first().click();
await page.waitForTimeout(200);
await fillByLabel(page, 'Город', coopData.city);
await fillByLabel(page, 'Юридический адрес регистрации', coopData.full_address);
await fillByLabel(page, 'Фактический адрес', coopData.fact_address);
await fillByLabel(page, 'ИНН организации', coopData.inn);
await fillByLabel(page, 'ОГРН организации', coopData.ogrn);
await fillByLabel(page, 'КПП организации', coopData.kpp);
await fillByLabel(page, 'Наименование банка', coopData.bank.name);
await fillByLabel(page, 'Корреспондентский счет', coopData.bank.corr);
await fillByLabel(page, 'БИК', coopData.bank.bik);
await fillByLabel(page, 'КПП банка', coopData.bank.kpp);
await fillByLabel(page, 'Номер счета', coopData.bank.account);
await page.locator('label:has-text("Валюта")').first().click();
await page.locator('.q-menu .q-item:has-text("RUB")').first().click();
await page.waitForTimeout(200);
await page.waitForTimeout(500);
await shot(page, '05-organization-filled', 'Все поля кооператива заполнены: реквизиты, представитель, банковские данные.');
await page.locator('.q-checkbox:has-text("персональных данных")').click();
await page.waitForTimeout(300);
await clickContinue(page);
// --- 6. GenerateAccount. ---
await page.locator('.q-stepper__tab--active:has-text("Получите приватный ключ")').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForSelector('label:has-text("Приватный ключ")', { timeout: 15_000 });
await page.waitForTimeout(800);
await shot(page, '07-generate-account', 'Шаг «Получите приватный ключ» — UI сгенерировал приватный ключ (WIF) кооператива.');
const wif = await page.locator('label:has-text("Приватный ключ")').first().locator('input').inputValue();
if (wif && fixturePath) {
fs.writeFileSync(
fixturePath,
JSON.stringify({ ...coopData, email, wif }, null, 2),
'utf8',
);
console.log(`[signUpAsOrganization] фикстура сохранена → ${fixturePath} (wif=${wif.slice(0, 6)}…)`);
}
await page.locator('.q-checkbox:has-text("сохранил ключ")').click();
await page.waitForTimeout(300);
// Перехват GraphQL registerAccount для извлечения username.
const registerAccountResponse = page.waitForResponse(
(resp) =>
resp.url().includes('/graphql') &&
resp.request().method() === 'POST' &&
resp.request().postData()?.includes('registerAccount'),
{ timeout: 30_000 },
);
await clickContinue(page);
let username = null;
try {
const resp = await registerAccountResponse;
const json = await resp.json();
username = json?.data?.registerAccount?.account?.username || null;
} catch (e) {
console.warn('[signUpAsOrganization] не удалось перехватить registerAccount response:', e.message);
}
// --- 8. ReadStatement — ждём активации и отрисованного заявления. ---
await page.locator('.q-stepper__tab--active:has-text("Ознакомьтесь")').first()
.waitFor({ state: 'visible', timeout: 60_000 });
await page.locator('.statement').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.statement').first().scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
await shot(
page,
'09-statement-read',
'Шаг «Заявление о вступлении» — на странице отображается сгенерированный текст заявления с реквизитами кооператива.',
);
// Обновляем фикстуру username'ом.
if (username && fixturePath && fs.existsSync(fixturePath)) {
const existing = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
fs.writeFileSync(
fixturePath,
JSON.stringify({ ...existing, username }, null, 2),
'utf8',
);
console.log(`[signUpAsOrganization] username сохранён в фикстуру: ${username}`);
}
return { username, wif, email };
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@coopenomics/docs-harness",
"version": "2026.5.11-4",
"version": "2026.5.14-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@coopenomics/docs-harness",
"version": "2026.5.11-4",
"version": "2026.5.14-2",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.59.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/docs-harness",
"version": "2026.5.11-4",
"version": "2026.5.14-2",
"private": true,
"description": "Playwright harness для съёмки скриншотов в документацию (см. bin/shoot.mjs)",
"type": "module",
@@ -0,0 +1,60 @@
// Сценарий: регистрация второго кооператива в Восходе (до экрана ReadStatement).
//
// Останавливаемся именно на ReadStatement, чтобы захватить шот сгенерированного
// заявления. Подпись + отправка + оплата вступительного — в сценарии
// 02-sign-and-submit (он повторяет signUp + продолжает).
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { signUpAsOrganization } from '../../lib/registrator-signup.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
const COOP_DATA = {
emailLocal: 'chairman.partner1',
short_name: 'Партнёр-1',
full_name: 'Потребительский кооператив «Партнёр-1»',
type: 'Потребительский Кооператив',
represented_by: {
last_name: 'Иванов',
first_name: 'Иван',
middle_name: 'Иванович',
based_on: 'решения общего собрания №1 от 01.01.2026 г',
position: 'Председатель совета',
},
phone: '+7 (901) 123-45-67',
country: 'Россия',
city: 'Москва',
full_address: 'г. Москва, ул. Тестовая, д. 1, оф. 100',
fact_address: 'г. Москва, ул. Тестовая, д. 1, оф. 100',
inn: '7700000001',
ogrn: '1027700000001',
kpp: '770001001',
bank: {
name: 'ПАО Сбербанк',
corr: '30101810400000000225',
bik: '044525225',
kpp: '773601001',
account: '40703810500000000001',
currency: 'RUB',
},
};
export const meta = {
title: 'Регистрация второго кооператива (Партнёр-1) в Восходе',
docPath: 'new/onboarding/01-register-coop.md',
assetsDir: 'assets/new/onboarding/01-register-coop',
role: 'anonymous',
};
export default async ({ page, shot, env }) => {
await signUpAsOrganization({
page,
shot,
env,
coopData: COOP_DATA,
fixturePath: COOP_FIXTURE_PATH,
});
};
@@ -0,0 +1,154 @@
// Сценарий: подписание заявления и его отправка оператору.
//
// Продолжает 01: повторяет signUp до ReadStatement (через общий helper),
// затем галочки → SignStatement (рисуем подпись на canvas) → автоматическая
// подпись всех документов регистрации + отправка заявления → PayInitial.
//
// На PayInitial останавливаемся (без оплаты): шот платёжного экрана уходит
// в документацию. Реальная оплата + WaitingRegistration + переход в реестр —
// в следующем сценарии 03 (под chairman'ом) либо в отдельной интеграции с
// mock-провайдером.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { signUpAsOrganization } from '../../lib/registrator-signup.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
const COOP_DATA = {
emailLocal: 'chairman.partner1',
short_name: 'Партнёр-1',
full_name: 'Потребительский кооператив «Партнёр-1»',
type: 'Потребительский Кооператив',
represented_by: {
last_name: 'Иванов',
first_name: 'Иван',
middle_name: 'Иванович',
based_on: 'решения общего собрания №1 от 01.01.2026 г',
position: 'Председатель совета',
},
phone: '+7 (901) 123-45-67',
country: 'Россия',
city: 'Москва',
full_address: 'г. Москва, ул. Тестовая, д. 1, оф. 100',
fact_address: 'г. Москва, ул. Тестовая, д. 1, оф. 100',
inn: '7700000001',
ogrn: '1027700000001',
kpp: '770001001',
bank: {
name: 'ПАО Сбербанк',
corr: '30101810400000000225',
bik: '044525225',
kpp: '773601001',
account: '40703810500000000001',
currency: 'RUB',
},
};
export const meta = {
title: 'Подписание заявления и отправка оператору (Партнёр-1 → Восход)',
docPath: 'new/onboarding/02-sign-and-submit.md',
assetsDir: 'assets/new/onboarding/02-sign-and-submit',
role: 'anonymous',
};
export default async ({ page, shot, env }) => {
// Шаги 0109: signUp до ReadStatement.
await signUpAsOrganization({
page,
shot,
env,
coopData: COOP_DATA,
fixturePath: COOP_FIXTURE_PATH,
});
// --- 10. Отмечаем все галочки соглашений (включая Устав). ---
// Чекбоксы в q-stepper показываются во всех q-step'ах, но видимые только в
// активном (остальные display:none). Кликаем все .q-checkbox в активной зоне.
// Сначала откатываем диалоги ReadAgreementDialog'а, если они открываются от
// клика по тексту: используем native click на input[type=checkbox] внутри
// .q-checkbox, чтобы случайно не активировать ссылку на устав или
// ReadAgreementDialog.
await page.evaluate(() => {
const tab = document.querySelector('.q-stepper__tab--active');
// Активный q-step расположен рядом — Quasar держит общий контейнер
// .q-stepper__step с одинаковой структурой. Найдём контейнер активного
// шага через ближайший .q-stepper__step, потомка которого .statement.
const stepBody = Array
.from(document.querySelectorAll('.q-stepper__step'))
.find((el) => el.querySelector('.statement'));
if (!stepBody) return;
const checkboxes = stepBody.querySelectorAll(
'.q-checkbox input[type=checkbox]:not(:checked)',
);
checkboxes.forEach((cb) => cb.click());
});
await page.waitForTimeout(500);
await shot(
page,
'10-statement-agreements-checked',
'Все галочки соглашений на ReadStatement проставлены (Устав + динамические соглашения), кнопка «Продолжить» активна.',
);
await page.locator('button:has-text("Продолжить")').first().click();
// --- 11. SignStatement — собственноручная подпись через canvas. ---
await page.locator('.q-stepper__tab--active:has-text("Подпишите заявление")').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.signature-container canvas').first()
.waitFor({ state: 'visible', timeout: 15_000 });
await page.waitForTimeout(400);
await shot(
page,
'11-sign-statement-empty',
'Шаг «Подпишите заявление»: канвас для собственноручной подписи (пустой).',
);
// Рисуем «подпись»: несколько штрихов мышью внутри canvas. UI проверяет
// что canvas содержит хоть один ненулевой пиксель.
const canvas = page.locator('.signature-container canvas').first();
const box = await canvas.boundingBox();
if (!box) throw new Error('canvas boundingBox is null');
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
// Длинная диагональ + петля — гарантированно даёт ненулевые пиксели.
await page.mouse.move(box.x + 40, cy - 20);
await page.mouse.down();
await page.mouse.move(cx, cy + 40, { steps: 12 });
await page.mouse.move(box.x + box.width - 40, cy - 30, { steps: 12 });
await page.mouse.up();
await page.mouse.move(cx - 60, cy + 60);
await page.mouse.down();
await page.mouse.move(cx + 60, cy - 20, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(300);
await shot(
page,
'12-sign-statement-drawn',
'Подпись нанесена на canvas. После клика «Продолжить» клиент подписывает все документы регистрации приватным ключом.',
);
// Клик «Продолжить» запускает цепочку: signAllRegistrationDocuments → signStatement →
// sendStatementAndAgreements (это серия GraphQL мутаций; UI в это время
// крутит Loader с текстом «Подписываем ...»).
await page.locator('button:has-text("Продолжить")').first().click();
// --- 12. Переход на PayInitial. ---
// Может занять несколько секунд (несколько GraphQL мутаций подряд).
await page.locator('.q-stepper__tab--active:has-text("вступительный взнос")').first()
.waitFor({ state: 'visible', timeout: 60_000 });
// Ждём готовности данных оплаты (createInitialPayment).
await page.locator('text=Пожалуйста, совершите оплату').first()
.waitFor({ state: 'visible', timeout: 30_000 });
await page.waitForTimeout(500);
await shot(
page,
'13-pay-initial',
'Шаг «Оплатите вступительный взнос»: заявление подписано и отправлено, UI показывает сумму вступительного + минимального паевого взноса и реквизиты для оплаты.',
);
// Сценарий завершается на экране оплаты. Дальше — либо ручная оплата (qrpay/yookassa),
// либо mock-провайдер на тестовом контуре.
};
@@ -0,0 +1,147 @@
// Сценарий: оператор Восхода открывает реестр запросов одобрений
// (Approvals) и подтверждает заявку нового пайщика (Партнёр-1).
//
// Реальный путь chairman'а в Восходе:
// /:coopname/chairman/approvals — страница «Запросы одобрений»
// (см. components/desktop/extensions/chairman/install.ts, name='approvals').
//
// Approval-запись появляется автоматически после того, как пайщик оплатил
// вступительный взнос. На локальном стенде без mock-провайдера платежей
// approval для нашей свежей заявки может отсутствовать — тогда сценарий
// делает шот пустого реестра (для документации этого достаточно).
//
// Зависит от: 02-sign-and-submit (фикстура partner1.json содержит username).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { env, dismissOnboardingDialogs } from '../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
export const meta = {
title: 'Оператор Восхода открывает реестр запросов одобрений и подтверждает заявку Партнёр-1',
docPath: 'new/onboarding/03-operator-approve.md',
assetsDir: 'assets/new/onboarding/03-operator-approve',
role: 'chairman',
};
export default async ({ page, shot }) => {
const partner = JSON.parse(fs.readFileSync(COOP_FIXTURE_PATH, 'utf8'));
if (!partner.username) {
throw new Error('Фикстура partner1.json не содержит username — сначала запустите 02-sign-and-submit');
}
// --- 1. Логин под chairman'ом Восхода. ---
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForSelector('button:has-text("Войти")', { timeout: 30_000 });
await page.locator('label:has-text("электронную почту")').first().locator('input').fill(env.CHAIRMAN_EMAIL);
await page.locator('label:has-text("ключ доступа")').first().locator('input').fill(env.CHAIRMAN_WIF);
await page.locator('button:has-text("Войти")').click();
await page.waitForURL(/\/(chairman|participant|soviet)/, { timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
// Каскад онбординг-диалогов (Положение ЦПП, ЭП, политика, пользовательское
// соглашение) — скрыть через CSS. См. dismissOnboardingDialogs.
await dismissOnboardingDialogs(page);
await page.waitForTimeout(500);
await shot(page, '01-chairman-home', 'Стартовый экран chairman\'а Восхода (онбординг-диалоги обработаны).');
// --- 2. Идём в реестр одобрений. ---
// Восход — SPA с hash-роутингом, поэтому URL формируется как `/#/<route>`.
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/chairman/approvals`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(1500);
console.log(`[03-operator-approve] url after goto: ${page.url()}`);
await dismissOnboardingDialogs(page);
await page.waitForTimeout(800);
console.log(`[03-operator-approve] url after dismiss: ${page.url()}`);
const domSummary = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'));
const visiblePortals = portals.filter((p) => getComputedStyle(p).display !== 'none');
return {
totalPortals: portals.length,
visiblePortals: visiblePortals.length,
visibleIds: visiblePortals.map((p) => p.id),
hasQTable: !!document.querySelector('.q-table'),
hasApprovalsText: /Запросы одобрений/.test(document.body.textContent || ''),
bodyKids: Array.from(document.body.children).map((c) => `${c.tagName}#${c.id || '-'}`).slice(0, 10),
};
});
console.log(`[03-operator-approve] dom: ${JSON.stringify(domSummary)}`);
// Дождаться q-table или текста-плейсхолдера.
await Promise.race([
page.locator('.q-table').first().waitFor({ state: 'visible', timeout: 30_000 }),
page.locator('text=У председателя нет запросов').first().waitFor({ state: 'visible', timeout: 30_000 }),
]).catch(() => {});
await page.waitForTimeout(800);
await shot(
page,
'02-approvals-list',
'Реестр «Запросы одобрений» (chairman): таблица с фильтром «Ожидает» — все pending-заявки кооператива.',
);
// --- 3. Ищем строку нашего Партнёр-1 по username. ---
const row = page.locator(`tr:has(td:has-text("${partner.username}"))`).first();
const found = await row.waitFor({ state: 'visible', timeout: 5_000 })
.then(() => true).catch(() => false);
if (!found) {
// Approval для свежей заявки не появился (на тестовом стенде платёж
// pending → approval не создан). Шотим пустой реестр и завершаемся —
// дальше backend-зависимая часть, обрабатывается отдельно.
console.log(`[03-operator-approve] approval для ${partner.username} не найден в реестре PENDING — backend не подтвердил платёж`);
await shot(
page,
'03-approval-row-missing',
`Заявка пайщика ${partner.username} (${partner.short_name}) ещё не появилась в реестре одобрений — на тестовом стенде без mock-провайдера платёж висит со статусом «pending» и approval не создаётся.`,
);
return;
}
await row.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
await shot(
page,
'03-approval-row',
`Строка заявки пайщика ${partner.username} (${partner.short_name}) в реестре одобрений — статус «Ожидает».`,
);
// --- 4. Разворачиваем строку — видим тело документа заявления. ---
await row.locator('button').first().click();
await page.waitForTimeout(800);
await shot(
page,
'04-approval-expanded',
'Развёрнутая строка заявки: chairman видит полный текст документа заявления на вступление.',
);
// --- 5. Клик «Одобрить». ---
// ConfirmApprovalButton — в той же row, отдельная кнопка в колонке Действия.
const confirmBtn = row.locator('button:has-text("Одобрить"), button:has-text("Подтвердить")').first();
await confirmBtn.click({ timeout: 5_000 });
// Возможна модалка подтверждения — подождём и шотнем если появится.
const dialogAppeared = await page.locator('[id^="q-portal--dialog--"]').first()
.waitFor({ state: 'visible', timeout: 5_000 })
.then(() => true).catch(() => false);
if (dialogAppeared) {
await page.waitForTimeout(400);
await shot(
page,
'05-confirm-dialog',
'Модалка подтверждения «Одобрить заявку» — chairman подписывает решение совета приватным ключом.',
);
// «Подтвердить» / «Подписать» в модалке.
const dialogConfirm = page.locator('[id^="q-portal--dialog--"] button:has-text("Подтвердить"), [id^="q-portal--dialog--"] button:has-text("Подписать"), [id^="q-portal--dialog--"] button:has-text("Одобрить")').first();
await dialogConfirm.click({ timeout: 5_000 }).catch(() => {});
}
await page.waitForTimeout(2500);
await shot(
page,
'06-after-confirm',
'Реестр после одобрения: статус заявки Партнёр-1 переходит в «Одобрено», approval уходит из фильтра «Ожидает».',
);
};
@@ -0,0 +1,180 @@
// Сценарий: Партнёр-1 (cooperative-пайщик) подписывает соглашение
// о подключении к платформе Кооперативной Экономики.
//
// Поток UX (см. components/desktop/src/widgets/ConnectionAgreementStepper):
// /:coopname/connect → ConnectionAgreementPage
// ├── isLoading → WindowLoader
// ├── !is_providered → «обратитесь в ПК ВОСХОД» (заглушка)
// └── is_providered → ConnectionAgreementStepper (7 шагов):
// 0 UnionMembership (опц.) → 1 Intro → 2 Form
// → 3 Agreement → 4 DomainValidation
// → 5 ApprovalWaiting → 6 Installation
//
// Зависимости:
// * partner1.json (фикстура signUp из 02-sign-and-submit) — wif/username/email
//
// Backend-зависимости (на тестовом стенде без mock-провайдера платежей
// не выполнены, поэтому сценарий фиксирует то, что реально видит пользователь):
// * Партнёр-1 ещё не принят как пайщик Восхода (approval pending) —
// при попытке открыть /connect возможен редирект или закрытая страница.
// * is_providered ставится после InstallCooperative от Союза — на стенде
// этого нет, поэтому ожидаем заглушку, а не стэппер.
//
// Цель шотов: задокументировать UX-точку — что именно увидит председатель
// Партнёр-1, когда зайдёт на страницу подключения до того, как Восход
// активирует его инстанс.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { env, dismissOnboardingDialogs } from '../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COOP_FIXTURE_PATH = path.resolve(__dirname, '../../state/cooperatives/partner1.json');
export const meta = {
title: 'Партнёр-1 открывает «Соглашение о подключении» к платформе Восхода',
docPath: 'new/onboarding/04-sign-connection-agreement.md',
assetsDir: 'assets/new/onboarding/04-sign-connection-agreement',
role: 'user',
};
export default async ({ page, shot }) => {
const partner = JSON.parse(fs.readFileSync(COOP_FIXTURE_PATH, 'utf8'));
if (!partner.username || !partner.wif || !partner.email) {
throw new Error('partner1.json без username/wif/email — сначала запустите 02-sign-and-submit');
}
// --- 1. Логин Партнёр-1. ---
// На текущем тестовом стенде Партнёр-1 ещё не принят советом Восхода как
// пайщик (платёж pending → approval не создан → status='joined',
// is_registered=false в pg.users). Login UI в этом случае:
// 1) отрабатывает Mutations.Auth.Login (sdk Client.login) успешно,
// 2) sees !session.isRegistrationComplete → router.push({name:'signup'})
// Это легитимный UX для шага «вы ещё не приняты — продолжите регистрацию».
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForSelector('button:has-text("Войти")', { timeout: 30_000 });
await page.locator('label:has-text("электронную почту")').first().locator('input').fill(partner.email);
await page.locator('label:has-text("ключ доступа")').first().locator('input').fill(partner.wif);
await page.locator('button:has-text("Войти")').click();
// Допускаем оба исхода: signup-redirect (пайщик ещё не принят) либо успешный
// вход в participant/chairman/soviet/user.
await page.waitForURL(/\/(chairman|participant|soviet|user|signup)/, { timeout: 30_000 }).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await dismissOnboardingDialogs(page);
await page.waitForTimeout(500);
console.log(`[04-sign-connection-agreement] url after login: ${page.url()}`);
const isInSignup = /\/signup\b/.test(page.url());
if (isInSignup) {
// Партнёр-1 не пускают глубже — пайщик ещё в статусе joined.
// Это и есть документируемая точка: оператору Восхода нужно сначала
// подтвердить платёж и принять заявку (см. сценарий 03 + будущие шаги
// активации), и только тогда станет доступна страница «Подключение».
await shot(
page,
'01-blocked-signup',
'Партнёр-1 после первого логина: Восход ещё не принял его как пайщика '
+ '(status=joined, is_registered=false), поэтому фронт перенаправляет '
+ 'обратно на /signup. Доступ к «Соглашению о подключении» откроется '
+ 'после одобрения советом (сценарий 03) и активации (сценарий 05).',
);
return;
}
await shot(
page,
'01-partner-home',
'Стартовый экран Партнёр-1 после первого входа в Восход (онбординг-диалоги обработаны).',
);
// --- 2. Переходим в раздел «Подключение» (/:coopname/connect). ---
// Маршрут зарегистрирован в extensions/participant/install.ts с условием
// isCoop === true && coopname === 'voskhod' — открыт всем cooperative-
// пайщикам Восхода.
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/connect`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await dismissOnboardingDialogs(page);
await page.waitForTimeout(1500);
console.log(`[04-sign-connection-agreement] url after goto /connect: ${page.url()}`);
const pageState = await page.evaluate(() => {
const text = (document.body.textContent || '').replace(/\s+/g, ' ');
return {
hasStepper: !!document.querySelector('.q-stepper'),
hasLoader: !!document.querySelector('.window-loader, [class*="loader"]'),
hasFallback: /обратитесь в ПК ВОСХОД|Перейти на сайт/.test(text),
hasProvider: /провайдер|is_providered/.test(text),
activeStepTitle: document.querySelector('.q-stepper__tab--active .q-stepper__title')?.textContent?.trim() || null,
bodyTextStart: text.trim().slice(0, 300),
};
});
console.log(`[04-sign-connection-agreement] page state: ${JSON.stringify(pageState)}`);
await shot(
page,
'02-connection-page',
pageState.hasFallback
? 'Страница «Подключение» (Партнёр-1): кооператив ещё не подключён к платформе — UI приглашает обратиться к провайдеру ПК ВОСХОД.'
: pageState.hasStepper
? `Страница «Подключение» (Партнёр-1): открыт мастер ConnectionAgreementStepper (активный шаг: ${pageState.activeStepTitle || '—'}).`
: 'Страница «Подключение» (Партнёр-1): начальное состояние после открытия маршрута /:coopname/connect.',
);
// --- 3. Если виден стэппер — пытаемся пройти по шагам и снимать каждый. ---
if (pageState.hasStepper) {
// Шаг 0 (опционально) — UnionMembership. На большинстве конфигов is_unioned=false,
// и Vue стартует с шага 1 (Intro). Если активен 0 — снимаем и пробуем «Продолжить».
for (let i = 0; i < 7; i++) {
const stepInfo = await page.evaluate(() => {
const active = document.querySelector('.q-stepper__tab--active');
if (!active) return null;
return {
title: active.querySelector('.q-stepper__title')?.textContent?.trim() || null,
stepIndex: Array.from(document.querySelectorAll('.q-stepper__tab')).indexOf(active),
};
});
if (!stepInfo) {
console.log(`[04-sign-connection-agreement] no active step at iter ${i}`);
break;
}
const safeTitle = (stepInfo.title || `step-${i}`).replace(/[^а-яА-ЯёЁa-zA-Z0-9-]+/g, '-').toLowerCase();
await shot(
page,
`03-step-${i + 1}-${safeTitle}`.slice(0, 60),
`Шаг «${stepInfo.title || `${stepInfo.stepIndex}`}» мастера соглашения о подключении.`,
);
// Ищем кнопку продвижения дальше. Возможные тексты: «Продолжить», «Далее»,
// «Подписать», «Сгенерировать», «Подтвердить». На шагах ожидания
// (DomainValidation/ApprovalWaiting/Installation) их нет — там backend
// двигает stepper автоматически.
const nextBtn = page.locator(
'.q-stepper__step.q-stepper--active-content button:has-text("Продолжить"), '
+ '.q-stepper__step.q-stepper--active-content button:has-text("Далее"), '
+ '.q-stepper__step.q-stepper--active-content button:has-text("Подписать"), '
+ '.q-stepper__step.q-stepper--active-content button:has-text("Подтвердить")',
).first();
const canAdvance = await nextBtn.isVisible({ timeout: 1_000 }).catch(() => false);
if (!canAdvance) {
console.log(`[04-sign-connection-agreement] step ${i + 1}: no advance-button — stopping`);
break;
}
const isDisabled = await nextBtn.isDisabled().catch(() => true);
if (isDisabled) {
console.log(`[04-sign-connection-agreement] step ${i + 1}: advance-button disabled (нужны действия пайщика)`);
break;
}
await nextBtn.click({ timeout: 5_000 }).catch((e) => console.log(`[04-sign-connection-agreement] click fail: ${e.message}`));
await page.waitForTimeout(1500);
}
}
// --- 4. Финальный шот — что осталось на экране. ---
await shot(
page,
'99-final-state',
'Финальное состояние страницы подключения после прохода по доступным шагам — дальше требуется активация Восходом (см. сценарий 05).',
);
};
@@ -0,0 +1,122 @@
// Сценарий: оператор Восхода открывает страницу инстанса в провайдерском
// дашборде (provider-frontend) и видит карточку активации кооператива.
//
// Контекст архитектуры:
// provider-frontend (Quasar SSR/SPA, dev на :3009)
// ├─ / HomePage (welcome)
// ├─ /cooperative/:username CooperativePage — карточка инстанса
// └─ /instances/:username/activate ActivationPage — форма активации
//
// provider-backend (NestJS, dev на :3005)
// ├─ GET /instances/all список всех инстансов (ServerSecretGuard)
// ├─ GET /instances/:username один инстанс
// ├─ POST /instances/activate активация
// └─ POST /instances/:username/resume
//
// Особенность тестового стенда:
// provider-backend требует заголовок `server-secret` (ServerSecretGuard),
// а production-фронт работает с открытым origin'ом без CORS-заголовков.
// Чтобы playwright смог дёрнуть API из браузерного origin'а localhost:3009,
// подмешиваем server-secret + CORS Access-Control-Allow-Origin через
// page.route() — это интерсепт на стороне playwright, backend нетронут.
const PROVIDER_FRONTEND = process.env.PROVIDER_FRONTEND_URL || 'http://localhost:3009';
const PROVIDER_BACKEND = process.env.PROVIDER_BACKEND_URL || 'http://127.0.0.1:3005';
const SERVER_SECRET = process.env.PROVIDER_SERVER_SECRET || 'e2e-fixture-secret-DO-NOT-USE-IN-PROD';
const TARGET_COOP = process.env.TARGET_COOP || 'voskhod';
export const meta = {
title: 'Оператор открывает карточку инстанса кооператива в провайдере',
docPath: 'new/onboarding/05-activate-from-registry.md',
assetsDir: 'assets/new/onboarding/05-activate-from-registry',
role: 'operator',
};
export default async ({ page, context, shot }) => {
// --- CORS + server-secret для всех запросов к provider-backend ---
await context.route('**/instances/**', async (route) => {
const request = route.request();
if (request.method() === 'OPTIONS') {
await route.fulfill({
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'server-secret,content-type,authorization',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
},
});
return;
}
const headers = { ...request.headers(), 'server-secret': SERVER_SECRET };
const response = await route.fetch({ headers });
let body = await response.text();
// Шаблон CooperativePage.vue рассчитан на плоскую структуру
// organization.private_data.{details,full_name,...} а API возвращает
// вложенную organization.private_data.organization_data.{...}.
// Расплющиваем — иначе Vue падает на undefined и карточка пуста.
try {
const json = JSON.parse(body);
const flatten = (item) => {
const data = item?.organization?.private_data;
if (data?.organization_data && !data.details) {
item.organization.private_data = {
...data.organization_data,
type: data.type,
};
}
return item;
};
const fixed = Array.isArray(json) ? json.map(flatten) : flatten(json);
body = JSON.stringify(fixed);
} catch {
/* не JSON — пропускаем */
}
await route.fulfill({
status: response.status(),
contentType: 'application/json',
headers: {
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
},
body,
});
});
// --- 1. Главная страница: welcome + список кооперативов в левом меню. ---
await page.goto(PROVIDER_FRONTEND, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(1500);
console.log(`[05-activate-from-registry] url after home: ${page.url()}`);
await shot(
page,
'01-provider-home',
'Главный экран провайдерского дашборда «Центр Поставки»: welcome-блок и левое меню «КООПЕРАТИВЫ» (заполняется списком инстансов из provider-backend).',
);
// --- 2. Карточка инстанса конкретного кооператива. ---
await page.goto(`${PROVIDER_FRONTEND}/#/cooperative/${TARGET_COOP}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'02-cooperative-card',
`Карточка инстанса кооператива «${TARGET_COOP}»: основная информация (домен, port, ansible host), серверная и организационная сводка из provider-backend.`,
);
// --- 3. Страница активации инстанса (если CooperativePage отдаёт кнопку
// «Активировать» — её клик уводит сюда). ---
await page.goto(`${PROVIDER_FRONTEND}/#/instances/${TARGET_COOP}/activate`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'03-activation-page',
`Страница активации инстанса «${TARGET_COOP}»: оператор выбирает preset (vm.nano / vm.small / vm.medium / vm.large), затем нажимает «Активировать» — provider-backend дёргает Ansible и поднимает кооператив.`,
);
};
@@ -0,0 +1,118 @@
// Сценарий: оператор ждёт пока инстанс кооператива перейдёт в статус ACTIVE.
//
// Жизненный цикл инстанса (provider-backend → instance.status):
// PENDING → инстанс зарегистрирован, идут Ansible-шаги (progress 0-100%)
// INSTALL → выполняется установка
// ACTIVE → кооператив поднят и доступен на своём домене
// ERROR → шаг провалился, требуется resume
// BLOCKED → инстанс остановлен оператором
// RENT → инстанс на платном хостинге
//
// UI-механика: ConnectionAgreementPage.startInstanceAutoRefresh(30000) делает
// polling каждые 30 секунд, провайдерский дашборд тоже периодически
// обновляет данные. Сценарий имитирует два момента — pending (текущее
// состояние тестового стенда) и ACTIVE (мокаем status, чтобы показать
// финальный экран без реальной активации Ansible'ом).
const PROVIDER_FRONTEND = process.env.PROVIDER_FRONTEND_URL || 'http://localhost:3009';
const PROVIDER_BACKEND = process.env.PROVIDER_BACKEND_URL || 'http://127.0.0.1:3005';
const SERVER_SECRET = process.env.PROVIDER_SERVER_SECRET || 'e2e-fixture-secret-DO-NOT-USE-IN-PROD';
const TARGET_COOP = process.env.TARGET_COOP || 'voskhod';
export const meta = {
title: 'Оператор наблюдает за активацией инстанса до статуса ACTIVE',
docPath: 'new/onboarding/06-wait-instance-active.md',
assetsDir: 'assets/new/onboarding/06-wait-instance-active',
role: 'operator',
};
const PROVIDER_BACKEND_PATTERN = '**/instances/**';
// Мутатор JSON-ответа: расплющивает organization_data → private_data
// (CooperativePage.vue ожидает плоскую структуру); опционально подменяет
// status/progress инстанса для имитации ACTIVE-состояния.
function rewriteResponse(body, overrideInstance = null) {
try {
const json = JSON.parse(body);
const flatten = (item) => {
const data = item?.organization?.private_data;
if (data?.organization_data && !data.details) {
item.organization.private_data = { ...data.organization_data, type: data.type };
}
if (overrideInstance && item?.instance) {
Object.assign(item.instance, overrideInstance);
}
return item;
};
const fixed = Array.isArray(json) ? json.map(flatten) : flatten(json);
return JSON.stringify(fixed);
} catch {
return body;
}
}
async function withRoute(context, overrideInstance) {
await context.unrouteAll().catch(() => {});
await context.route(PROVIDER_BACKEND_PATTERN, async (route) => {
const request = route.request();
if (request.method() === 'OPTIONS') {
await route.fulfill({
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'server-secret,content-type,authorization',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
},
});
return;
}
const headers = { ...request.headers(), 'server-secret': SERVER_SECRET };
const response = await route.fetch({ headers });
const body = rewriteResponse(await response.text(), overrideInstance);
await route.fulfill({
status: response.status(),
contentType: 'application/json',
headers: {
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
},
body,
});
});
}
export default async ({ page, context, shot }) => {
// --- 1. Реальное pending-состояние (progress 50%, status='pending'). ---
await withRoute(context, null);
await page.goto(`${PROVIDER_FRONTEND}/#/cooperative/${TARGET_COOP}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'01-pending',
`Инстанс «${TARGET_COOP}» в состоянии PENDING: Ansible-шаг 50% «try preset 1 (vm.nano)». UI обновляется автоматически каждые 30 секунд через startInstanceAutoRefresh.`,
);
// --- 2. Подменяем status=active/progress=100 — экран ACTIVE. ---
await withRoute(context, {
status: 'active',
progress: 100,
is_valid: true,
is_delegated: true,
blockchain_status: 'active',
error_message: '',
failed_status: '',
});
// Перезагружаем страницу, чтобы Vue заново подтянул mocked-данные.
await page.reload({ waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(2000);
await shot(
page,
'02-active',
`Инстанс «${TARGET_COOP}» в состоянии ACTIVE: установка завершена, blockchain активен, кооператив доступен на своём домене. Пайщики Партнёр-1 в это время получают ConnectionDashboard вместо ConnectionAgreementStepper.`,
);
};
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/docs",
"type": "module",
"version": "2026.5.11-4",
"version": "2026.5.14-2",
"private": true,
"packageManager": "pnpm@9.9.0",
"description": "Документация для монорепозитория",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/factory",
"type": "module",
"version": "2026.5.11-4",
"version": "2026.5.14-2",
"description": "Фабрика юридических документов кооператива",
"author": "Alex Ant <chairman.voskhod@gmail.com>",
"license": "MIT",

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