Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b84b02f1b4 | |||
| c33ec53230 | |||
| 7dcca3a2ae | |||
| 70752b2caa | |||
| 8c5568bcf8 | |||
| 30b79ad1f7 | |||
| 87747d1782 | |||
| f7a182e51f | |||
| 9774abb296 | |||
| 724cd381cc | |||
| 5aa1a21a97 | |||
| 933e5b5ffe | |||
| d886d780fc | |||
| 68b37efbe9 | |||
| e810d3152f | |||
| 99c22b0064 | |||
| 4c0fbefca9 | |||
| 8fdc99a7f9 | |||
| 8d27c4e245 | |||
| 0e3ccb4356 | |||
| 306835d0fc | |||
| 10e5b83ac9 | |||
| 33d936e5c8 | |||
| a73f07fa0c | |||
| 77f358e865 | |||
| 2afc71c281 | |||
| 9cf19aa8fe | |||
| 4b8121b0b7 |
@@ -39,10 +39,3 @@ MINIO_ROOT_PASSWORD=minioadmin
|
||||
CHAIN_URL=http://127.0.0.1:8888
|
||||
API_URL=http://127.0.0.1:2998/v1/graphql
|
||||
MONGODB_URL=mongodb://127.0.0.1:27017
|
||||
|
||||
# apps-catalog proxy (Story 9.5.b). Если оба пустые — мы в degraded mode,
|
||||
# резолвер appsCatalogRemotePackages вернёт пустой список без ошибки.
|
||||
# URL = DNS-имя контейнера ca-admin в общей сети mono-shared (см.
|
||||
# docker network inspect mono-shared).
|
||||
APPS_CATALOG_URL=http://apps-catalog-ca-admin:3002
|
||||
APPS_CATALOG_API_KEY=dev-fixture-admin-api-key-DO-NOT-USE-IN-PROD-12345
|
||||
|
||||
+160
-104
@@ -5,12 +5,8 @@ name: Release
|
||||
# Порядок (через jobs.needs):
|
||||
# 1) release — контракты + контейнеры + webhook деплоя (атомарно)
|
||||
# 2) publish-packages — npm publish через lerna (если не -alpha)
|
||||
# 3) trigger-coopenomics — workflow_dispatch сборки сайта C9S/coopenomics на
|
||||
# Gitea (если не -alpha + ветка main); он сам тянет
|
||||
# mono и пересобирает доки.
|
||||
#
|
||||
# publish-docs (gh-pages на github.com) удалён 2026-05-25 — после переезда на
|
||||
# Gitea Pages-публикация невалидна; доки деплоятся через C9S/coopenomics.
|
||||
# 3) publish-docs — mkdocs + standards-site → gh-pages (если не -alpha + ветка main)
|
||||
# 4) trigger-coopenomics — repository_dispatch в coopenomics/coopenomics (тот же гейт, что docs)
|
||||
#
|
||||
# Зачем последовательно: пакеты/доки/внешний триггер не должны уезжать,
|
||||
# если релиз контрактов или контейнеров провалился. Раньше четыре workflow'а
|
||||
@@ -38,7 +34,16 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# Гейт TS-типов перед сборкой образов. Reusable workflow `typecheck.yaml`
|
||||
# запускает vue-tsc на desktop и tsc --noEmit на controller. Нужно потому,
|
||||
# что `lerna run build` в корневом Dockerfile НЕ ловит TS-ошибки:
|
||||
# quasar build идёт через esbuild с выключенным vueTsc, а у controller'а
|
||||
# build-скрипта вообще нет (ts-node в рантайме).
|
||||
typecheck:
|
||||
uses: ./.github/workflows/typecheck.yaml
|
||||
|
||||
release:
|
||||
needs: typecheck
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.resolve.outputs.tag_name }}
|
||||
@@ -122,41 +127,14 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# === Этап 1: контракты ===
|
||||
# CDT ставим из .deb (C9S/cdt v4.2.0) прямо в окружение job'а и
|
||||
# компилируем напрямую — БЕЗ вложенного docker. Почему не build-all.sh:
|
||||
# тот монтирует $(pwd):/project в sibling-контейнер, а под Gitea
|
||||
# act_runner сам job исполняется в контейнере → хостовый демон не видит
|
||||
# этот путь, /project пуст, cmake падает "no CMakeLists.txt".
|
||||
# build-all.sh остаётся для локальной сборки (оборачивает тот же
|
||||
# build_contracts_cdt.sh в docker).
|
||||
# CMakeLists хардкодит toolchain /cdt/build/...; .deb кладёт CDT в
|
||||
# /usr/opt/cdt/4.2.0, а сам CDTWasmToolchain.cmake указывает на /usr
|
||||
# абсолютно — поэтому симлинк /cdt/build → /usr/opt/cdt/4.2.0 сводит пути
|
||||
# без правки CMakeLists.
|
||||
|
||||
- name: Install CDT 4.2.0 toolchain (.deb)
|
||||
run: |
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO apt-get update
|
||||
# build-essential — для host-компилятора (project() в CMakeLists);
|
||||
# libz3-4/libtinfo6/libxml2/zlib1g — рантайм бинарей CDT (clang-9,
|
||||
# ld.lld и т.д.). Пакет cdt объявляет только libcurl4-gnutls-dev,
|
||||
# поэтому остальные .so ставим явно — в образе dicoop/blockchain они
|
||||
# были из сборки исходников, из .deb не тянутся.
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates cmake make build-essential \
|
||||
libz3-4 libtinfo6 libxml2 zlib1g
|
||||
curl -fsSL -o /tmp/cdt.deb \
|
||||
https://git.coopenomics.world/C9S/cdt/releases/download/v4.2.0/cdt_4.2.0-1_amd64.deb
|
||||
$SUDO apt-get install -y /tmp/cdt.deb
|
||||
$SUDO mkdir -p /cdt
|
||||
$SUDO ln -sfn /usr/opt/cdt/4.2.0 /cdt/build
|
||||
cdt-cpp --version || true
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
- name: Compile contracts
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
./build_contracts_cdt.sh "$BUILD_MODE"
|
||||
./build-all.sh "$BUILD_MODE"
|
||||
echo "--- build/contracts ---"
|
||||
ls -la build/contracts/
|
||||
|
||||
@@ -237,17 +215,6 @@ jobs:
|
||||
- name: Build and push contracts image
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
# Ретрай push'а: резолвинг registry-1.docker.io на runner'е изредка
|
||||
# моргает (DNS-таймаут к 127.0.0.53) — 3 попытки с паузой.
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
IMAGE="dicoop/contracts"
|
||||
SHORT_SHA="${SHA::7}"
|
||||
docker build \
|
||||
@@ -256,14 +223,14 @@ jobs:
|
||||
--label "build.mode=$BUILD_MODE" \
|
||||
-t "$IMAGE:$CONTRACTS_TAG" \
|
||||
./docker/.context
|
||||
dpush "$IMAGE:$CONTRACTS_TAG"
|
||||
docker push "$IMAGE:$CONTRACTS_TAG"
|
||||
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
dpush "$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"
|
||||
dpush "$IMAGE:$CONTRACTS_EXTRA"
|
||||
docker push "$IMAGE:$CONTRACTS_EXTRA"
|
||||
fi
|
||||
|
||||
- name: Verify pushed contracts image
|
||||
@@ -276,31 +243,15 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
run: |
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
|
||||
dpush "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
|
||||
dpush dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
- name: Build and push service images
|
||||
run: |
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
build_service() {
|
||||
local SVC="$1" PKG="$2" CMD="$3"
|
||||
local DOCKERFILE="Dockerfile.$SVC"
|
||||
@@ -309,10 +260,10 @@ jobs:
|
||||
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
|
||||
} > "$DOCKERFILE"
|
||||
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
|
||||
dpush "dicoop/$SVC:$TAG_NAME"
|
||||
docker push "dicoop/$SVC:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
|
||||
dpush "dicoop/$SVC:latest"
|
||||
docker push "dicoop/$SVC:latest"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -363,44 +314,149 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# ============================================================================
|
||||
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
|
||||
# publish-docs — mkdocs + standards-site → gh-pages (бывший publish-docs.yaml).
|
||||
# Гейт: не-alpha + ветка main.
|
||||
#
|
||||
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
|
||||
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
|
||||
# repository_dispatch — только workflow_dispatch, поэтому дёргаем целевой
|
||||
# workflow через Gitea API с PAT (secret DOCS_DISPATCH_TOKEN — префикс GITEA_
|
||||
# у секретов зарезервирован Gitea, нельзя; права write на C9S/coopenomics).
|
||||
# Целевой workflow слушает workflow_dispatch с входами
|
||||
# mono_sha/mono_ref.
|
||||
# ВНИМАНИЕ: gh-pages step пушит на github.com/coopenomics/mono — это GitHub
|
||||
# Pages. При переезде на Gitea Actions нужно будет либо подложить PAT для
|
||||
# внешнего push'а в GitHub, либо переключить публикацию на Gitea Pages.
|
||||
# ============================================================================
|
||||
publish-docs:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Версия pnpm берётся из `packageManager` корневого package.json,
|
||||
# синхронно с publish-packages job'ом и Dockerfile'ами.
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install Python requirements
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install mkdocs-material mkdocs-macros-plugin mkdocs-section-index pymdown-extensions
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm install
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Patch spectaql-config.yml for CI
|
||||
run: |
|
||||
sed -i.bak "0,/url:.*/s|url:.*|url: 'https://testnet.coopenomics.world/backend/v1/graphql'|" spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Show patched spectaql-config.yml
|
||||
run: cat spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Build cooptypes
|
||||
run: pnpm run build
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Generate controller docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Copy controller docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/graphql
|
||||
cp -r ./components/controller/docs/* ./components/docs/docs/graphql/
|
||||
|
||||
- name: Generate sdk docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/sdk
|
||||
|
||||
- name: Copy sdk docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/sdk
|
||||
cp -r ./components/sdk/docs/* ./components/docs/docs/sdk/
|
||||
|
||||
- name: Generate cooptypes docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Copy cooptypes docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/cooptypes
|
||||
cp -r ./components/cooptypes/docs/* ./components/docs/docs/cooptypes/
|
||||
|
||||
# ── standards-site → /standards/ на docs.цифровой-кооператив.рф ──
|
||||
# Отдельный Vue-сайт с BPMN-графом кооперативных стандартов,
|
||||
# публикуется как поддиректория на том же домене. Vite собирает
|
||||
# с base='/standards/' (см. vite.config.ts), относительные пути
|
||||
# внутри dist/ и hash-router работают корректно.
|
||||
# dist кладём в docs/standards/ ДО mkdocs build — так же, как
|
||||
# graphql/sdk/cooptypes; mkdocs сам включит его в итоговый site/.
|
||||
- name: Build standards-site
|
||||
run: pnpm run build
|
||||
working-directory: ./components/contracts/standards-site
|
||||
|
||||
- name: Copy standards-site into docs/standards
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/standards
|
||||
cp -r ./components/contracts/standards-site/dist/* ./components/docs/docs/standards/
|
||||
|
||||
- name: Build docs (mkdocs)
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
mkdocs build
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Remove specific large file before publishing
|
||||
run: |
|
||||
rm -f ./components/docs/site/sdk/typedoc.json
|
||||
if [ -f "./components/docs/site/sdk/typedoc.json" ]; then
|
||||
echo "ERROR: typedoc.json still exists!"
|
||||
exit 1
|
||||
else
|
||||
echo "SUCCESS: typedoc.json removed successfully"
|
||||
fi
|
||||
|
||||
- name: Publish to GitHub Pages
|
||||
run: npx gh-pages --nojekyll -d site --repo https://x-access-token:${GITHUB_TOKEN}@github.com/coopenomics/mono.git
|
||||
working-directory: ./components/docs
|
||||
env:
|
||||
GIT_AUTHOR_NAME: github-actions
|
||||
GIT_AUTHOR_EMAIL: github-actions@github.com
|
||||
GIT_COMMITTER_NAME: github-actions
|
||||
GIT_COMMITTER_EMAIL: github-actions@github.com
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger docs deployment webhook
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
curl -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
|
||||
|
||||
# ============================================================================
|
||||
# trigger-coopenomics-docs — repository_dispatch в coopenomics/coopenomics
|
||||
# (бывший build-contracts-docs.yaml). Гейт идентичен publish-docs.
|
||||
# ============================================================================
|
||||
trigger-coopenomics-docs:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger coopenomics website rebuild (Gitea workflow_dispatch)
|
||||
run: |
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: token ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ref":"master","inputs":{"mono_sha":"${{ github.sha }}","mono_ref":"${{ github.ref }}"}}' \
|
||||
"${{ github.server_url }}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
|
||||
|
||||
# ============================================================================
|
||||
# trigger-mono-docs — деплой ВТОРОЙ документации (сайт доков mono) через
|
||||
# webhook DOCS_DEPLOY_WEBHOOK_URL. Отдельная от coopenomics публикация —
|
||||
# обе доки уезжают синхронно по релизу. Извлечён из бывшего publish-docs:
|
||||
# gh-pages на github.com выпилен как мёртвый, остался реальный deploy-webhook
|
||||
# (приёмник деплоит доки на своей стороне). Гейт: не-alpha + ветка main.
|
||||
# ============================================================================
|
||||
trigger-mono-docs:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger docs deployment webhook
|
||||
run: |
|
||||
curl -fsSL -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
|
||||
- name: Trigger Coopenomics deployment
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.COOPENOMICS_PAT }}
|
||||
repository: coopenomics/coopenomics
|
||||
event-type: deploy_from_mono
|
||||
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
|
||||
|
||||
@@ -13,25 +13,17 @@ name: Typecheck
|
||||
#
|
||||
# Триггеры:
|
||||
# - pull_request на dev — гейт перед мерджем в основную ветку;
|
||||
# - workflow_call — оставлен для переиспользования, но release.yaml его
|
||||
# больше НЕ вызывает (гейт из релиза убран как избыточный — типы
|
||||
# проверяются на PR в dev до того, как код доедет до тэга).
|
||||
# - workflow_call — переиспользуется из release.yaml как `needs:` у release-job,
|
||||
# чтобы битый тэг не уехал в DockerHub.
|
||||
#
|
||||
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт на dev достаточен).
|
||||
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт достаточен,
|
||||
# а тэги покрывает workflow_call из release.yaml).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
workflow_call:
|
||||
|
||||
# Новый push в PR-ветку отменяет ещё бегущий typecheck по той же ветке —
|
||||
# иначе 30-минутные прогоны копятся очередью. Группа по head_ref (исходная
|
||||
# ветка PR), для workflow_call fallback на ref. Отмена только своих же
|
||||
# прежних прогонов, разные PR друг друга не трогают.
|
||||
concurrency:
|
||||
group: typecheck-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
desktop:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
lerna-debug.log
|
||||
components/controller/graph.png
|
||||
blockchain-data/
|
||||
@@ -23,5 +22,3 @@ components/contracts/cpp/ledger2/scripts/out/
|
||||
|
||||
.env.testnet
|
||||
.pnpm-store
|
||||
schema.gql
|
||||
.claude
|
||||
|
||||
@@ -184,35 +184,6 @@ await sendPOST('/v1/graphql', { query: QUERY, variables });
|
||||
|
||||
Канон — `features/Branch/CreateBranch/{api,model}/index.ts`: тип в model `export type IXxxInput = Mutations.X.Y.IInput['data']`; функция в api `function (data: IXxxInput) { ... variables: { data } }`. Не делать `as` cast'ов.
|
||||
|
||||
## ⚠️ ДИЗАЙН-КАНОН desktop — ОБЯЗАТЕЛЕН К ИСПОЛЬЗОВАНИЮ
|
||||
|
||||
**ПРИ ЛЮБОЙ ВЁРСТКЕ В `components/desktop/` НЕ ВЫДУМЫВАТЬ СТИЛИ.** Всегда сначала свериться с каноном. Не строй гипотез о цветах/радиусах/типографике/паттернах из памяти — иди и читай.
|
||||
|
||||
**Полная спецификация канона — в skill `/mono-desktop-canon`** (правила обёрток, цвета/токены, иконки, структура страницы, stop-signals). Здесь — короткая выжимка; при расхождении побеждает skill.
|
||||
|
||||
**Источник истины — в самом репозитории** (НЕ внешние HTML/прототипы):
|
||||
|
||||
| Что | Путь |
|
||||
|---|---|
|
||||
| Токены палитры/типографики/spacing/radii (`--p-*`) | `components/desktop/src/css/mono-platform/tokens.css` |
|
||||
| Прототип-классы компонентов + typography-утилиты `.t-*` | `components/desktop/src/css/mono-platform/components.css` |
|
||||
| Оверрайды Quasar под канон | `components/desktop/src/css/mono-platform/quasar-canon.css` |
|
||||
| Минимальная SCSS-палитра Quasar (`$primary`/`$dark`) | `components/desktop/src/css/quasar.variables.scss` |
|
||||
| Обёртки / доменные блоки / layout | `components/desktop/src/shared/ui/{base,domain,layout}/` |
|
||||
| **Живой эталон** (`/_dev/ui` в dev-сборке) | `components/desktop/src/pages/_dev/ui/index.vue` |
|
||||
|
||||
**При сомнении — открыть `tokens.css` и `_dev/ui/index.vue`, смотреть как сделано там.** Внешний `shared/MONO Design System.html` и `auth-prototype/` каноном НЕ являются — это устаревшие прототипы.
|
||||
|
||||
**Запреты:**
|
||||
- Экран собирается из готовых компонентов: `shared/ui/base` (вместо сырых `q-input`/`q-btn`/`q-card`/`q-table`/`q-chip`/`q-dialog`/`q-select`), `shared/ui/domain` (WalletCard, DataRow, DocumentRow, IdentityPanel…), `shared/ui/layout` (PageHead, PageTabs, AppHeader/AppDrawer). Голый Quasar — только где обёртки нет (`q-icon`, `q-toggle`, `q-list`, `q-menu`, `q-tooltip`, `q-tabs`, `q-separator`, `q-spinner`, `q-inner-loading`…). Props обёрток не угадывать — читать `*.types.ts` рядом.
|
||||
- Цвет — только токены `var(--p-*)` (поверхности `--p-surface*`, текст `--p-ink*`, линии `--p-line*`, акцент `--p-primary`, статусы `--p-pos/neg/warn/info`) либо utility-классы/color-props. Никаких сырых hex/rgb. Темы light/dark переключаются через `[data-theme]` на `<html>` — токены следуют сами.
|
||||
- Spacing/радиусы/типографика — токены `--p-1..--p-10` (4px…72px), `--p-r-sm/md/lg/xl`, `--p-fs-*`/`--p-lh-*` либо классы `.t-*`. Без хардкод-px.
|
||||
- Иконки — `q-icon(name='…')` именами Material Icons. **FontAwesome (`fa-*`) запрещён** — заменять на Material-эквивалент попутно.
|
||||
- Запрещённые/удаляемые токены: `--mp-*` (старый `marketplace-tokens.scss`), `--tr-*`, `--ds-*`. Легитимны только `--p-*`, `--q-*` (Quasar автоген), `--prog-*`.
|
||||
- Никаких локальных переопределений `.q-btn`/`.q-card`/`.q-notification` в feature-файлах — Quasar-overrides централизованы в `quasar-canon.css`.
|
||||
|
||||
**Кейс 2026-05-28:** при сомнении в каноне пошёл искать его во внешнем HTML/auth-prototype вместо репозитория — перевёрстал не туда, переделывал. SoT — репо (`tokens.css` + `_dev/ui`) и skill `/mono-desktop-canon`, не внешние файлы.
|
||||
|
||||
## Frontend desktop — English имена
|
||||
|
||||
В `components/desktop/` и любом Vue/TS frontend коде **все имена идентификаторов — английские**:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.5.30-alpha-2",
|
||||
"version": "2026.5.23-4",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -362,9 +362,9 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
)
|
||||
.action(async (paths: string[]) => {
|
||||
const root = requireRoot()
|
||||
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity } = await runAdd(root, paths)
|
||||
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts } = await runAdd(root, paths)
|
||||
success(
|
||||
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}; не blago-сущности (нет type=project/issue/story во frontmatter): ${skippedNonEntity}.`,
|
||||
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}.`,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -38,22 +38,6 @@ export function parseBlagoMarkdown(raw: string): ParsedBlagoFile {
|
||||
}
|
||||
}
|
||||
|
||||
/** Не бросает: возвращает тип сущности blago, если файл — корректный project/issue/story; иначе undefined. */
|
||||
export function peekBlagoEntityType(raw: string): EntityFrontmatterType | undefined {
|
||||
let file: ReturnType<typeof matter>
|
||||
try {
|
||||
file = matter(raw)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
const t = file.data?.type
|
||||
if (t === 'project' || t === 'issue' || t === 'story') {
|
||||
return t
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function serializeBlagoMarkdown(data: Record<string, unknown>, body: string): string {
|
||||
const trimmed = body.endsWith('\n') ? body : `${body}\n`
|
||||
// matter.stringify(string, data) сначала парсит string как frontmatter — body, начинающийся
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { peekBlagoEntityType } from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { expandBlagoUserTargetsToRelativePaths } from './capital-target-expand.js'
|
||||
import { isBlagoSyncExcludedDirName, isIgnoredRelativePath, loadBlagoIgnoreRules } from './ignore.js'
|
||||
@@ -29,39 +28,19 @@ async function collectMarkdownFiles(absDir: string): Promise<string[]> {
|
||||
return out
|
||||
}
|
||||
|
||||
type FileCheck =
|
||||
| { kind: 'unreadable' }
|
||||
| { kind: 'non-entity' }
|
||||
| { kind: 'clean' }
|
||||
| { kind: 'dirty' }
|
||||
|
||||
/**
|
||||
* Решение по одному файлу для staging:
|
||||
* — нечитаемый → dirty (push сам сообщит вменяемую ошибку),
|
||||
* — без корректного frontmatter type → non-entity (README/notes/CLAUDE/AGENTS и пр. не отправляем),
|
||||
* — есть в индексе и sha256 совпал → clean,
|
||||
* — иначе dirty.
|
||||
*/
|
||||
async function classifyFileForAdd(
|
||||
root: string,
|
||||
rel: string,
|
||||
index: Awaited<ReturnType<typeof loadIndex>>,
|
||||
): Promise<FileCheck> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(path.join(root, rel), 'utf8')
|
||||
}
|
||||
catch {
|
||||
return { kind: 'unreadable' }
|
||||
}
|
||||
if (peekBlagoEntityType(raw) === undefined) {
|
||||
return { kind: 'non-entity' }
|
||||
}
|
||||
/** true — файл стоит поставить в staging: нет в индексе или SHA256 на диске ≠ content_etag_local. */
|
||||
async function isDirtyVsIndex(root: string, rel: string, index: Awaited<ReturnType<typeof loadIndex>>): Promise<boolean> {
|
||||
const entry = findByRelativePath(index, rel)
|
||||
if (!entry) {
|
||||
return { kind: 'dirty' }
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, rel), 'utf8')
|
||||
return sha256Hex(raw) !== entry.content_etag_local
|
||||
}
|
||||
catch {
|
||||
return true
|
||||
}
|
||||
return sha256Hex(raw) === entry.content_etag_local ? { kind: 'clean' } : { kind: 'dirty' }
|
||||
}
|
||||
|
||||
export interface RunAddResult {
|
||||
@@ -71,8 +50,6 @@ export interface RunAddResult {
|
||||
skippedIgnored: number
|
||||
/** messages/ и meetings/ — только синхронизация с сервера, в push не идут */
|
||||
skippedPullOnlyArtifacts: number
|
||||
/** Файлы без корректного blago-frontmatter (README, notes, CLAUDE/AGENTS и пр.) — в staging не попадают */
|
||||
skippedNonEntity: number
|
||||
}
|
||||
|
||||
export async function runAdd(root: string, targets: string[]): Promise<RunAddResult> {
|
||||
@@ -88,7 +65,6 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
|
||||
let skippedUnchanged = 0
|
||||
let skippedIgnored = 0
|
||||
let skippedPullOnlyArtifacts = 0
|
||||
let skippedNonEntity = 0
|
||||
|
||||
for (const t of expanded) {
|
||||
const abs = path.resolve(root, t)
|
||||
@@ -110,12 +86,7 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
|
||||
skippedPullOnlyArtifacts += 1
|
||||
continue
|
||||
}
|
||||
const check = await classifyFileForAdd(root, rel, index)
|
||||
if (check.kind === 'non-entity') {
|
||||
skippedNonEntity += 1
|
||||
continue
|
||||
}
|
||||
if (check.kind === 'clean') {
|
||||
if (!(await isDirtyVsIndex(root, rel, index))) {
|
||||
skippedUnchanged += 1
|
||||
continue
|
||||
}
|
||||
@@ -125,5 +96,5 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
|
||||
|
||||
const stagedPaths = [...set].sort()
|
||||
await saveStaging(root, { paths: stagedPaths })
|
||||
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity }
|
||||
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts }
|
||||
}
|
||||
|
||||
@@ -9,15 +9,12 @@ export interface CommunicationCursorsFile {
|
||||
messageLastTsByRoom: Record<string, number>
|
||||
/** project_hash → ISO instant: транскрипции с endedAt ≤ этого момента уже выгружены */
|
||||
transcriptionLastEndedExclusiveByProject: Record<string, string>
|
||||
/** matrixRoomId → ISO instant: транскрипции непроектной комнаты с endedAt ≤ этого момента уже выгружены */
|
||||
transcriptionLastEndedExclusiveByRoom: Record<string, string>
|
||||
}
|
||||
|
||||
function empty(): CommunicationCursorsFile {
|
||||
return {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +32,6 @@ export async function loadCommunicationCursors(root: string): Promise<Communicat
|
||||
&& typeof parsed.transcriptionLastEndedExclusiveByProject === 'object'
|
||||
? { ...parsed.transcriptionLastEndedExclusiveByProject }
|
||||
: {},
|
||||
transcriptionLastEndedExclusiveByRoom:
|
||||
parsed.transcriptionLastEndedExclusiveByRoom !== undefined
|
||||
&& typeof parsed.transcriptionLastEndedExclusiveByRoom === 'object'
|
||||
? { ...parsed.transcriptionLastEndedExclusiveByRoom }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -43,6 +43,7 @@ export function projectCommunicationDayToMarkdown(
|
||||
}
|
||||
|
||||
export interface TranscriptionHeaderModel {
|
||||
matrixRoomId: string
|
||||
roomId: string
|
||||
startedAt: Date | string
|
||||
endedAt: Date | string | null | undefined
|
||||
@@ -70,6 +71,7 @@ export function renderCallTranscriptionMarkdown(
|
||||
const header = [
|
||||
`# Транскрипция звонка`,
|
||||
``,
|
||||
`- Matrix room: \`${transcription.matrixRoomId}\``,
|
||||
`- LiveKit room: \`${transcription.roomId}\``,
|
||||
`- Начало: ${start.toISOString()}`,
|
||||
`- Окончание: ${end ? end.toISOString() : '—'}`,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
transcriptionMeetingFileStemUtc,
|
||||
type CommunicationDayLine,
|
||||
} from './communication-markdown.js'
|
||||
import { generateSlug, workspaceBasePath, type ProjectPathModel } from './layout.js'
|
||||
import { workspaceBasePath, type ProjectPathModel } from './layout.js'
|
||||
import { syncEntityFile } from './sync-entity-file.js'
|
||||
|
||||
interface ProjectRowLite {
|
||||
@@ -183,19 +183,6 @@ async function listRooms(ctx: AuthenticatedContext, projectHash: string) {
|
||||
return q[Queries.ChatCoop.ListProjectCommunicationRooms.name] ?? []
|
||||
}
|
||||
|
||||
/** Стабильная папка непроектной комнаты в `rooms/`. Системные — фиксированные, комнаты секретаря — slug + хвост id (уникальность). */
|
||||
function nonProjectRoomFolder(kind: string, matrixRoomId: string, displayLabel: string): string {
|
||||
if (kind === 'MEMBERS') {
|
||||
return 'komnata-paishchikov'
|
||||
}
|
||||
if (kind === 'COUNCIL') {
|
||||
return 'komnata-soveta'
|
||||
}
|
||||
const slug = generateSlug(displayLabel) || 'komnata'
|
||||
const shortId = createHash('sha256').update(matrixRoomId, 'utf8').digest('hex').slice(0, 6)
|
||||
return `${slug}-${shortId}`
|
||||
}
|
||||
|
||||
export async function pullProjectCommunicationArtifacts(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
@@ -211,7 +198,6 @@ export async function pullProjectCommunicationArtifacts(
|
||||
cursors = {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +356,7 @@ export async function pullProjectCommunicationArtifacts(
|
||||
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
|
||||
const md = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
matrixRoomId: String(tr.matrixRoomId),
|
||||
roomId: String(tr.roomId),
|
||||
startedAt,
|
||||
endedAt: endedAtTr,
|
||||
@@ -435,207 +422,3 @@ export async function pullProjectCommunicationArtifacts(
|
||||
warn(`Не удалось сохранить курсоры переписки: ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull переписки и транскрипций из комнат ВНЕ проектов Capital (пайщики, совет, комнаты секретаря).
|
||||
* Раскладка — отдельная верхняя папка `rooms/<folder>/{messages,meetings}/`, чтобы не смешивать с
|
||||
* проектными `meetings/`. Логика идентична проектной, но bucket = одна комната, курсор транскрипций — по matrixRoomId.
|
||||
*/
|
||||
export async function pullNonProjectCommunicationArtifacts(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
): Promise<void> {
|
||||
let rooms: { matrixRoomId: string, displayLabel: string, kind: string }[]
|
||||
try {
|
||||
const q = await ctx.client.Query(Queries.ChatCoop.ListNonProjectCommunicationRooms.query, {})
|
||||
rooms = (q[Queries.ChatCoop.ListNonProjectCommunicationRooms.name] ?? []) as typeof rooms
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Список непроектных комнат (chatcoopListNonProjectCommunicationRooms): ${formatThrownValue(e)}`)
|
||||
return
|
||||
}
|
||||
if (rooms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let cursors: CommunicationCursorsFile
|
||||
try {
|
||||
cursors = await loadCommunicationCursors(ctx.root)
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Курсоры переписки (комнаты): не удалось прочитать, начинаем с пустых: ${formatThrownValue(e)}`)
|
||||
cursors = {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
for (const room of rooms) {
|
||||
const folder = nonProjectRoomFolder(room.kind, room.matrixRoomId, room.displayLabel)
|
||||
const basePath = `rooms/${folder}`
|
||||
const roomTitle = room.displayLabel || room.matrixRoomId
|
||||
|
||||
// Сообщения комнаты — по календарным суткам UTC новее курсора.
|
||||
try {
|
||||
const last = cursors.messageLastTsByRoom[room.matrixRoomId]
|
||||
const afterTs = last ?? 0
|
||||
const datesQ = await ctx.client.Query(Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, afterOriginServerTsExclusive: afterTs } },
|
||||
})
|
||||
const dates = (datesQ[Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.name] ?? []).sort()
|
||||
for (const utcDate of dates) {
|
||||
const mq = await ctx.client.Query(Queries.ChatCoop.GetRoomMessagesForUtcDate.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, utcDate } },
|
||||
})
|
||||
const linesRaw = mq[Queries.ChatCoop.GetRoomMessagesForUtcDate.name] ?? []
|
||||
const lines: CommunicationDayLine[] = linesRaw.map(m => ({
|
||||
originServerTs: m.originServerTs,
|
||||
authorLabel: m.authorLabel,
|
||||
coopUsername: m.coopUsername,
|
||||
kind: String(m.kind),
|
||||
bodyText: m.bodyText,
|
||||
}))
|
||||
if (lines.length === 0) {
|
||||
continue
|
||||
}
|
||||
const content = projectCommunicationDayToMarkdown(roomTitle, room.matrixRoomId, utcDate, [
|
||||
{ displayLabel: room.displayLabel, matrixRoomId: room.matrixRoomId, lines },
|
||||
])
|
||||
const rel = `${basePath}/messages/${utcDate}.md`
|
||||
const entityHash = messageDayEntityHash(room.matrixRoomId, utcDate)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'room_message_day',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: `${utcDate}T23:59:59.999Z`,
|
||||
label: `переписка ${utcDate} (${room.matrixRoomId})`,
|
||||
})
|
||||
}
|
||||
|
||||
const maxQ = await ctx.client.Query(Queries.ChatCoop.GetMaxOriginServerTsForRoom.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId } },
|
||||
})
|
||||
const maxTs = maxQ[Queries.ChatCoop.GetMaxOriginServerTsForRoom.name] as number | null | undefined
|
||||
if (maxTs !== undefined && maxTs !== null && Number.isFinite(maxTs)) {
|
||||
cursors.messageLastTsByRoom[room.matrixRoomId] = maxTs
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Переписка Matrix, комната ${room.matrixRoomId} (${roomTitle}): ${formatThrownValue(e)}`)
|
||||
}
|
||||
|
||||
// Транскрипции звонков комнаты + sibling memo.
|
||||
try {
|
||||
const tExIso = cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId]
|
||||
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
|
||||
|
||||
interface TranscriptionCandidate {
|
||||
id: string
|
||||
endedAt: Date
|
||||
memo: string
|
||||
updatedAt: Date | undefined
|
||||
}
|
||||
const byId = new Map<string, TranscriptionCandidate>()
|
||||
const tq = await ctx.client.Query(Queries.ChatCoop.GetTranscriptions.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, limit: CHATCOOP_TRANSCRIPTIONS_QUERY_LIMIT, offset: 0 } },
|
||||
})
|
||||
const list = tq[Queries.ChatCoop.GetTranscriptions.name] ?? []
|
||||
for (const t of list) {
|
||||
const end = dateFromUnknown(t.endedAt)
|
||||
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
|
||||
continue
|
||||
}
|
||||
const prev = byId.get(t.id)
|
||||
if (!prev || end > prev.endedAt) {
|
||||
byId.set(t.id, {
|
||||
id: t.id,
|
||||
endedAt: end,
|
||||
memo: typeof t.memo === 'string' ? t.memo : '',
|
||||
updatedAt: dateFromUnknown(t.updatedAt),
|
||||
})
|
||||
}
|
||||
}
|
||||
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
|
||||
|
||||
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
|
||||
let maxEnded: Date | null = null
|
||||
for (const c of newMeetings) {
|
||||
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
|
||||
variables: { data: { id: c.id } },
|
||||
})
|
||||
const pack = packQ[Queries.ChatCoop.GetTranscription.name]
|
||||
if (!pack?.transcription || pack.transcription.status !== Zeus.TranscriptionStatus.COMPLETED) {
|
||||
continue
|
||||
}
|
||||
const tr = pack.transcription
|
||||
const startedAt: Date | string = dateFromUnknown(tr.startedAt) ?? (tr.startedAt as Date | string)
|
||||
const endedAtTr: Date | string | null | undefined
|
||||
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
|
||||
const md = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
roomId: String(tr.roomId),
|
||||
startedAt,
|
||||
endedAt: endedAtTr,
|
||||
},
|
||||
pack.segments.map(s => ({
|
||||
speakerName: s.speakerName,
|
||||
text: s.text,
|
||||
startOffset: s.startOffset,
|
||||
endOffset: s.endOffset,
|
||||
})),
|
||||
)
|
||||
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
|
||||
const rel = `${basePath}/meetings/${stem}.md`
|
||||
const entityHash = c.id.toLowerCase()
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'call_transcription',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content: md,
|
||||
remoteUpdatedAt: toUpdatedIso(c.endedAt),
|
||||
label: `транскрипция ${c.id}`,
|
||||
})
|
||||
if (!maxEnded || c.endedAt > maxEnded) {
|
||||
maxEnded = c.endedAt
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of allCompleted) {
|
||||
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
|
||||
const memoRel = `${basePath}/meetings/${stem}.memo.md`
|
||||
const memoRemoteUpdatedAt = toUpdatedIso(c.updatedAt ?? c.endedAt)
|
||||
await syncTranscriptionMemoFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
transcriptionId: c.id,
|
||||
relativePath: memoRel,
|
||||
serverMemo: c.memo,
|
||||
remoteUpdatedAtIso: memoRemoteUpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if (maxEnded) {
|
||||
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = maxEnded.toISOString()
|
||||
}
|
||||
else if (tExIso === undefined) {
|
||||
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Транскрипции звонков, комната ${room.matrixRoomId}: ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await saveCommunicationCursors(ctx.root, cursors)
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Не удалось сохранить курсоры переписки (комнаты): ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
storyFileRelativePath,
|
||||
workspaceBasePath,
|
||||
} from './layout.js'
|
||||
import { pullNonProjectCommunicationArtifacts, pullProjectCommunicationArtifacts } from './pull-communication.js'
|
||||
import { pullProjectCommunicationArtifacts } from './pull-communication.js'
|
||||
import { scaffoldBmadWorkspacesAfterPull } from './scaffold-bmad-workspace.js'
|
||||
import { syncEntityFile } from './sync-entity-file.js'
|
||||
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
|
||||
@@ -329,8 +329,6 @@ export async function runPull(ctx: AuthenticatedContext, options: RunPullOptions
|
||||
|
||||
await pullProjectCommunicationArtifacts(ctx, index, allProjects, projectByHash)
|
||||
|
||||
await pullNonProjectCommunicationArtifacts(ctx, index)
|
||||
|
||||
await scaffoldBmadWorkspacesAfterPull(ctx, allProjects, projectByHash)
|
||||
|
||||
await saveIndex(ctx.root, index)
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Mutations, Queries } from '@coopenomics/sdk'
|
||||
import { parseBlagoMarkdown, peekBlagoEntityType } from '../format/index.js'
|
||||
import { parseBlagoMarkdown } from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
import { warn } from '../ui/output.js'
|
||||
@@ -58,30 +58,6 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
|
||||
}
|
||||
staging = await loadStaging(ctx.root)
|
||||
}
|
||||
// Не-сущности (README/notes/CLAUDE/AGENTS и пр., в .md без type=project|issue|story) тоже не должны валить весь push.
|
||||
// Чистим их автоматически — иначе одна забытая заметка блокирует отправку реальных правок.
|
||||
const stagingAbs = (p: string): string => path.join(ctx.root, normalizeRelativePath(p))
|
||||
const nonEntityInStaging: string[] = []
|
||||
for (const p of staging.paths) {
|
||||
try {
|
||||
const raw = await fs.readFile(stagingAbs(p), 'utf8')
|
||||
if (peekBlagoEntityType(raw) === undefined) {
|
||||
nonEntityInStaging.push(normalizeRelativePath(p))
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// нечитаемый файл — пусть основной цикл бросит понятную ошибку с путём
|
||||
}
|
||||
}
|
||||
if (nonEntityInStaging.length > 0) {
|
||||
const drop = new Set(nonEntityInStaging)
|
||||
const kept = staging.paths.filter(p => !drop.has(normalizeRelativePath(p)))
|
||||
await saveStaging(ctx.root, { paths: [...new Set(kept.map(p => normalizeRelativePath(p)))].sort() })
|
||||
for (const p of nonEntityInStaging) {
|
||||
warn(`Убрано из staging (нет blago-frontmatter type=project|issue|story): ${p}`)
|
||||
}
|
||||
staging = await loadStaging(ctx.root)
|
||||
}
|
||||
if (staging.paths.length === 0) {
|
||||
throw new Error('Нечего отправлять. Добавьте файлы: blago add <путь | id проекта | projectId-issueId>')
|
||||
}
|
||||
@@ -99,24 +75,8 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
|
||||
}
|
||||
const abs = path.join(ctx.root, n)
|
||||
const raw = await fs.readFile(abs, 'utf8')
|
||||
let parsed: ReturnType<typeof parseBlagoMarkdown>
|
||||
try {
|
||||
parsed = parseBlagoMarkdown(raw)
|
||||
}
|
||||
catch (err) {
|
||||
// Без префикса путём из staging вылезает «type: undefined» без указания файла.
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`Файл «${n}»: ${msg}`)
|
||||
}
|
||||
let type: ReturnType<typeof validateParsedForPush>['type']
|
||||
let hash: string
|
||||
try {
|
||||
({ type, hash } = validateParsedForPush(parsed))
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`Файл «${n}»: ${msg}`)
|
||||
}
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
const { type, hash } = validateParsedForPush(parsed)
|
||||
const entry = findByHash(index, type, hash)
|
||||
const pKind = pendingKindForEntityType(type)
|
||||
const pending = pKind ? await findPendingForParsed(ctx.root, pKind, hash) : undefined
|
||||
|
||||
@@ -367,6 +367,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
|
||||
if (entry.entity_type === 'call_transcription') {
|
||||
interface TranscriptionRestorePack {
|
||||
transcription: {
|
||||
matrixRoomId: string
|
||||
roomId: string
|
||||
startedAt: Date | string
|
||||
endedAt: Date | string | null | undefined
|
||||
@@ -393,6 +394,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
|
||||
}
|
||||
const content = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
matrixRoomId: tr.matrixRoomId,
|
||||
roomId: tr.roomId,
|
||||
startedAt: tr.startedAt,
|
||||
endedAt: tr.endedAt,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.5.30-alpha-2",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
|
||||
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
|
||||
# Загружаем per-instance конфиг из корня репо.
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
@@ -10,34 +9,26 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Останавливаем и удаляем контейнеры (сеть НЕ трогаем — к ней может быть
|
||||
# подключён внешний контейнер вроде provider-backend; down её снёс бы с ошибкой).
|
||||
echo "Останавливаем и удаляем контейнеры..."
|
||||
docker compose rm -fsv mongo postgres monoredis cooparser coopback || true
|
||||
# Останавливаем и удаляем контейнеры вместе с volumes
|
||||
echo "Останавливаем и удаляем контейнеры с volumes..."
|
||||
docker compose down -v mongo postgres cooparser || true
|
||||
|
||||
# Останавливаем blockchain контейнер перед удалением данных
|
||||
echo "Останавливаем blockchain контейнер..."
|
||||
docker compose stop node || true
|
||||
|
||||
# Удаляем тома баз данных для чистого старта (named volumes rm -v не чистит)
|
||||
echo "Удаляем тома баз данных..."
|
||||
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT_DIR")}"
|
||||
docker volume rm "${PROJECT}_postgres_data" "${PROJECT}_mongo_data" 2>/dev/null || true
|
||||
|
||||
# Удаляем blockchain data через контейнер (alpine под root стирает данные
|
||||
# независимо от владельца — без sudo на любой ноде; на проде nodeos пишет под root).
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
sudo rm -rf ../blockchain-data/
|
||||
|
||||
# Пересоздаем и запускаем базы данных + Redis (monoredis).
|
||||
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
|
||||
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash.
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres monoredis
|
||||
docker compose up -d mongo postgres
|
||||
|
||||
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
|
||||
# Ждем готовности MongoDB
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
|
||||
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -51,7 +42,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
|
||||
done
|
||||
echo "PostgreSQL готов!"
|
||||
|
||||
# Запускаем boot процесс (clean: только программы Благорост/маркетплейс)
|
||||
# Запускаем boot процесс
|
||||
echo "Запускаем boot процесс..."
|
||||
pnpm run boot:clean
|
||||
|
||||
@@ -60,6 +51,6 @@ echo "Запускаем parser..."
|
||||
docker compose up -d cooparser
|
||||
|
||||
echo "Запускаем контроллер..."
|
||||
docker compose up -d --force-recreate coopback || true
|
||||
docker compose restart coopback || true
|
||||
|
||||
echo "Перезапуск завершен!"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
|
||||
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
|
||||
# Загружаем per-instance конфиг из корня репо.
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
@@ -10,32 +9,30 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Останавливаем контроллер перед очисткой данных
|
||||
echo "Останавливаем контроллер..."
|
||||
docker compose down coopback || true
|
||||
|
||||
# Останавливаем и удаляем контейнеры вместе с volumes
|
||||
echo "Останавливаем и удаляем контейнеры с volumes..."
|
||||
docker compose down -v mongo postgres monoredis cooparser coopback || true
|
||||
docker compose down -v mongo postgres cooparser || true
|
||||
|
||||
# Останавливаем blockchain контейнер перед удалением данных
|
||||
echo "Останавливаем blockchain контейнер..."
|
||||
docker compose stop node || true
|
||||
|
||||
# Удаляем blockchain data.
|
||||
# Контейнерный wipe (alpine под root) стирает данные независимо от их владельца —
|
||||
# без sudo на любой ноде (на Pi нет passwordless sudo; на проде nodeos пишет
|
||||
# данные под root). Единый способ с reboot.sh / clean_reboot.sh.
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
|
||||
sudo rm -rf ../blockchain-data/
|
||||
|
||||
# Пересоздаем и запускаем базы данных + Redis (monoredis).
|
||||
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
|
||||
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash,
|
||||
# и провайдер не может взять org-данные partner1 (PROVIDER_URL=coopback:2998).
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres monoredis
|
||||
docker compose up -d mongo postgres
|
||||
|
||||
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
|
||||
# Ждем готовности MongoDB
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
|
||||
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -49,7 +46,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
|
||||
done
|
||||
echo "PostgreSQL готов!"
|
||||
|
||||
# Запускаем boot процесс (расширенный: совет + пайщики; partner1 — при EXTRA_RENT=1)
|
||||
# Запускаем boot процесс
|
||||
echo "Запускаем boot процесс..."
|
||||
pnpm run boot:extra
|
||||
|
||||
@@ -58,6 +55,6 @@ echo "Запускаем parser..."
|
||||
docker compose up -d cooparser
|
||||
|
||||
echo "Запускаем контроллер..."
|
||||
docker compose up -d --force-recreate coopback || true
|
||||
docker compose up -d coopback
|
||||
|
||||
echo "Перезапуск завершен!"
|
||||
|
||||
@@ -291,46 +291,31 @@ export default class Blockchain {
|
||||
async activateFeature(feature: Feature) {
|
||||
await this.update_pass_instance()
|
||||
|
||||
try {
|
||||
await this.api.transact(
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
account: 'eosio',
|
||||
name: 'activate',
|
||||
authorization: [
|
||||
{
|
||||
actor: 'eosio',
|
||||
permission: 'active',
|
||||
},
|
||||
],
|
||||
data: {
|
||||
feature_digest: feature.hash,
|
||||
await this.api.transact(
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
account: 'eosio',
|
||||
name: 'activate',
|
||||
authorization: [
|
||||
{
|
||||
actor: 'eosio',
|
||||
permission: 'active',
|
||||
},
|
||||
],
|
||||
data: {
|
||||
feature_digest: feature.hash,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
blocksBehind: 3,
|
||||
expireSeconds: 30,
|
||||
},
|
||||
)
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
blocksBehind: 3,
|
||||
expireSeconds: 30,
|
||||
},
|
||||
)
|
||||
|
||||
console.log('Фича активирована: ', feature.name)
|
||||
}
|
||||
catch (e: any) {
|
||||
// На уже забутстрапленном (не обнулённом) чейне протокол-фича уже активна —
|
||||
// nodeos отдаёт protocol_feature_exception (code 3250000). Это не повод
|
||||
// ронять весь boot: фича на месте, нужный результат достигнут, продолжаем.
|
||||
const errName = e?.json?.error?.name
|
||||
const errCode = e?.json?.error?.code
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (errName === 'protocol_feature_exception' || errCode === 3250000 || /already activated/i.test(msg)) {
|
||||
console.warn(`Фича уже активирована, пропускаю: ${feature.name}`)
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
console.log('Фича активирована: ', feature.name)
|
||||
}
|
||||
|
||||
async createToken(params: TokenContract.Interfaces.ICreate) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"protocol_feature_type": "builtin",
|
||||
"dependencies": [],
|
||||
"description_digest": "2ad6b7aabba8edbc87b46f3340df4d0db0e4b65474db3e1cd5db33fb685a506b",
|
||||
"subjective_restrictions": {
|
||||
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
|
||||
"preactivation_required": true,
|
||||
"enabled": true
|
||||
},
|
||||
"builtin_feature_codename": "ASSERT_RECOVER_KEY_ACCOUNT"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import config from '../configs'
|
||||
import { initExtensionsInPostgres, initSystemStatus } from '../postgres-init'
|
||||
import { installExtraData, installInitialData, startInfra } from './infra'
|
||||
import { startCoop } from './cooperative'
|
||||
import { CooperativeClass, startCoop } from './cooperative'
|
||||
|
||||
export async function boot() {
|
||||
const blockchain = await startInfra()
|
||||
@@ -13,29 +14,18 @@ export async function boot() {
|
||||
}
|
||||
|
||||
export async function bootClean() {
|
||||
// Только инфраструктура: контракты, фичи, токен, системные параметры.
|
||||
// Ни совета, ни программ — программы (createPrograms) требуют существующий
|
||||
// совет и создаются в boot/bootExtra внутри installInitialData. В clean их
|
||||
// не делаем (иначе soviet::createprog падает с «Совет не найден»).
|
||||
await startInfra()
|
||||
const blockchain = await startInfra()
|
||||
|
||||
console.log('Создаём программы (Благорост и маркетплейс)')
|
||||
|
||||
const cooperative = new CooperativeClass(blockchain)
|
||||
await cooperative.createPrograms(config.provider)
|
||||
}
|
||||
|
||||
export async function bootExtra() {
|
||||
const blockchain = await startInfra()
|
||||
await installInitialData(blockchain, true) // Создать расширенный совет (устанавливает статус 'active' в MongoDB)
|
||||
|
||||
// installExtraData регистрирует partner1 как coop с auto-approve от провайдера —
|
||||
// это и есть программная on-chain активация, которая триггерит аренду VM в
|
||||
// провайдере (PENDING→RENT). boot:extra используется НЕ только для аренды
|
||||
// сервера (например, просто пересев совета/чейна для других задач), поэтому
|
||||
// провижининг partner1 включается ТОЛЬКО под флагом EXTRA_RENT=1.
|
||||
if (process.env.EXTRA_RENT === '1') {
|
||||
console.log('EXTRA_RENT=1 → installExtraData: провижининг partner1 (триггер аренды)')
|
||||
await installExtraData(blockchain) // Регистрируем partner1 как coop (active)
|
||||
}
|
||||
else {
|
||||
console.log('EXTRA_RENT не задан → пропускаем провижининг partner1 (аренда не запускается)')
|
||||
}
|
||||
await installExtraData(blockchain) // Добавить дополнительных пайщиков
|
||||
|
||||
console.log('Инициализируем статус системы в PostgreSQL')
|
||||
await initSystemStatus() // Устанавливает статус 'active' в PostgreSQL
|
||||
|
||||
@@ -15,9 +15,6 @@ import { sleep } from '../utils'
|
||||
import { generateRandomSHA256 } from '../utils/randomHash'
|
||||
import { initUsersInPostgres, initVaultInPostgres } from '../postgres-init'
|
||||
import { CooperativeClass } from './cooperative'
|
||||
import { signProgramAgreement } from './sign-program-agreement'
|
||||
import { fakeDocument } from '../tests/shared/fakeDocument'
|
||||
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
@@ -677,17 +674,10 @@ export async function installExtraData(blockchain: Blockchain) {
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
// Записываем partner1 в ЦПП Кошелька оператора (voskhod, program_id=1).
|
||||
// БЕЗ этого провайдерский performInitialTransfers (150 AXON на partner1 ДО
|
||||
// перехода в ACTIVE, см. provider CLAUDE.md §13) падает ассертом
|
||||
// eosio.token::is_can_transfer «Получатель не является участником целевой
|
||||
// потребительской программы кошелька» — и инстанс навсегда застревает в
|
||||
// INSTALL. wallet::signagree (auth voskhod@active) делает partner1 членом
|
||||
// ЦПП Кошелька. Та же механика, что для обычных пайщиков в participant.ts.
|
||||
console.log('Подписываем wallet-соглашение за partner1 (членство в ЦПП Кошелька)')
|
||||
await signProgramAgreement(blockchain, config.provider, account.username, walletProgramId, walletDraftId, fakeDocument)
|
||||
|
||||
// Ресурсы для партнёра: powerup CPU/NET.
|
||||
// Ресурсы для партнёра: powerup CPU/NET. transfer токенов пропускаем —
|
||||
// eosio.token::transfer падает на проверке membership в wallet program,
|
||||
// которая для partner1 не настроена. Для provider sync + Hostkey-flow
|
||||
// токены не нужны: tx от имени partner1 пойдут от soviet/admin.
|
||||
await blockchain.powerup({
|
||||
payer: 'eosio',
|
||||
receiver: account.username,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.5.30-alpha-2",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Локальная сборка всех контрактов: оборачивает build_contracts_cdt.sh в
|
||||
# CDT-образ. Сам cmake/make живёт в build_contracts_cdt.sh — он же
|
||||
# используется в CI (там CDT ставится из .deb, без docker), поэтому
|
||||
# флаги сборки в одном месте и не разъезжаются.
|
||||
|
||||
mode="${1:-prod}"
|
||||
|
||||
if [ "$mode" = "test" ]; then
|
||||
is_testnet="ON"
|
||||
else
|
||||
is_testnet="OFF"
|
||||
fi
|
||||
|
||||
docker run --rm --name cdt \
|
||||
--volume "$(pwd):/project" \
|
||||
-w /project \
|
||||
dicoop/blockchain:latest \
|
||||
/bin/bash -c "./build_contracts_cdt.sh $mode"
|
||||
/bin/bash -c "mkdir -p build && cd build && cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET=$is_testnet .. && make"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Компиляция всех контрактов кооперативной экономики через CDT.
|
||||
#
|
||||
# ВАЖНО: скрипт запускается УЖЕ внутри окружения с установленным CDT —
|
||||
# тулчейн ожидается по пути /cdt/build/lib/cmake/cdt/CDTWasmToolchain.cmake
|
||||
# (он захардкожен в CMakeLists.txt). Здесь НЕТ docker — только cmake/make.
|
||||
#
|
||||
# Два способа подготовить это окружение:
|
||||
# - локально: build-all.sh оборачивает скрипт в `docker run dicoop/blockchain:latest`
|
||||
# (в образе CDT уже лежит в /cdt/build);
|
||||
# - в CI: CDT ставится из .deb (C9S/cdt), а /cdt/build симлинкуется на
|
||||
# /usr/opt/cdt/<ver> (см. .github/workflows/release.yaml).
|
||||
#
|
||||
# Один источник cmake-флагов для обоих путей — чтобы локальная и CI-сборка
|
||||
# не разъезжались.
|
||||
set -euo pipefail
|
||||
|
||||
mode="${1:-prod}"
|
||||
if [ "$mode" = "test" ]; then
|
||||
is_testnet="ON"
|
||||
else
|
||||
is_testnet="OFF"
|
||||
fi
|
||||
|
||||
mkdir -p build
|
||||
cd build
|
||||
cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET="$is_testnet" ..
|
||||
make
|
||||
+2
-2
@@ -72,8 +72,8 @@ void capital::importcontrib(eosio::name coopname, eosio::name username, checksum
|
||||
auto idx = user_wallets.get_index<"byuserwallet"_n>();
|
||||
auto preimp_it = idx.find(combine_ids(ledger2_wallets::PREIMP_FUND.value, username.value));
|
||||
if (preimp_it != idx.end() && preimp_it->available.amount > 0) {
|
||||
// Поле blocked упразднено (2026-05-24): резерв/блокировка на кошельках
|
||||
// больше не используется, проверка preimp.blocked == 0 излишня.
|
||||
eosio::check(preimp_it->blocked.amount == 0,
|
||||
"preimp.blocked > 0 не поддерживается при импорте — обратиться в поддержку");
|
||||
Ledger2::apply(
|
||||
_capital, coopname, operations::capital::DROP_PREIMP,
|
||||
preimp_it->available, username, contributor_hash,
|
||||
|
||||
@@ -9,26 +9,23 @@ namespace Capital::Core {
|
||||
* @brief Совокупный баланс программы Благорост на уровне кооператива.
|
||||
*
|
||||
* Источник — ledger2 L2 (`wallets[BLAGOROST_FUND]`), агрегированный по всем
|
||||
* пайщикам. Σ L3.available[w.cap.blago] == L2.available (Эпик 3 invariant),
|
||||
* поэтому L2 — авторитетная сумма для CRPS-знаменателя. Если кошелёк ещё не
|
||||
* создан (никто не инвестировал) — нулевой баланс.
|
||||
*
|
||||
* Поле blocked упразднено (2026-05-24): субсчёт «заблокировано» больше не
|
||||
* используется, баланс кошелька = available.
|
||||
* пайщикам. Σ L3.{available, blocked}[w.cap.blago] == L2.{available, blocked}
|
||||
* (Эпик 3 invariant), поэтому L2 — авторитетная сумма для CRPS-знаменателя.
|
||||
* Если кошелёк ещё не создан (никто не инвестировал) — нулевой баланс.
|
||||
*/
|
||||
eosio::asset get_capital_program_share_balance(eosio::name coopname) {
|
||||
wallets2_index wallets(_ledger2, coopname.value);
|
||||
auto it = wallets.find(ledger2_wallets::BLAGOROST_FUND.value);
|
||||
if (it == wallets.end()) return eosio::asset(0, _root_govern_symbol);
|
||||
return it->available;
|
||||
return it->available + it->blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Доля пайщика в Благоросте (available) из ledger2 L3.
|
||||
* @brief Доля пайщика в Благоросте (available + blocked) из ledger2 L3.
|
||||
*
|
||||
* Источник — `userwallets[(w.cap.blago, username)]`. Запись существует
|
||||
* только пока available > 0 (Эпик 3 §6: auto-delete на нуле), поэтому
|
||||
* отсутствие = нулевой баланс. Поле blocked упразднено (2026-05-24).
|
||||
* только пока available + blocked > 0 (Эпик 3 §6: auto-delete на нуле),
|
||||
* поэтому отсутствие = нулевой баланс.
|
||||
*/
|
||||
eosio::asset get_capital_program_user_share_balance(eosio::name coopname, eosio::name username) {
|
||||
userwallets_index user_wallets(_ledger2, coopname.value);
|
||||
@@ -36,6 +33,6 @@ namespace Capital::Core {
|
||||
auto key = combine_ids(ledger2_wallets::BLAGOROST_FUND.value, username.value);
|
||||
auto it = idx.find(key);
|
||||
if (it == idx.end()) return eosio::asset(0, _root_govern_symbol);
|
||||
return it->available;
|
||||
return it->available + it->blocked;
|
||||
}
|
||||
}
|
||||
@@ -67,12 +67,12 @@ void ledger2::revert(eosio::name coopname,
|
||||
eosio::check(original_operation_id != 0, "revert: original_operation_id обязателен");
|
||||
|
||||
// -------- validate mirror_wallet_op --------
|
||||
// Зеркало revert — только TRANSFER (обмен wallet_from/wallet_to) либо BURN
|
||||
// (зеркало ISSUE: изъятие с wallet_from). Бывшие BLOCK/UNBLOCK упразднены
|
||||
// (2026-05-24); ISSUE/NONE как зеркало смысла не имеют.
|
||||
eosio::check(mirror_wallet_op == static_cast<uint8_t>(WalletOp::TRANSFER) ||
|
||||
mirror_wallet_op == static_cast<uint8_t>(WalletOp::BURN),
|
||||
"revert: mirror_wallet_op должен быть TRANSFER или BURN");
|
||||
eosio::check(mirror_wallet_op <= 5, "revert: неизвестный mirror_wallet_op");
|
||||
// Не позволяем откатывать через BLOCK/UNBLOCK — они асимметричны и нет
|
||||
// адекватного зеркала в одной операции (BLOCK + UNBLOCK — обратные сами по себе).
|
||||
eosio::check(mirror_wallet_op != static_cast<uint8_t>(WalletOp::BLOCK) &&
|
||||
mirror_wallet_op != static_cast<uint8_t>(WalletOp::UNBLOCK),
|
||||
"revert: BLOCK/UNBLOCK не подлежат откату через revert (они симметричны сами себе)");
|
||||
|
||||
// -------- validate mirror wallets/accounts --------
|
||||
if (mirror_wallet_from.value != 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @brief Атомарная операция по кошельку (issue/transfer/burn).
|
||||
* @brief Атомарная операция по кошельку (issue/transfer/block/unblock/burn/burn_blocked).
|
||||
*
|
||||
* Внутренний action ledger2 — вызывается только через inline из apply().
|
||||
* Auth: только сам ledger2 (require_auth(get_self())).
|
||||
@@ -53,15 +53,13 @@ void ledger2::walletop(eosio::name coopname,
|
||||
eosio::check(amount.symbol == _root_govern_symbol,
|
||||
"walletop: некорректный символ валюты");
|
||||
eosio::check(memo.size() < 256, "walletop: memo > 255");
|
||||
// Допустимы только ISSUE(0), TRANSFER(1), BURN(4). NONE(5) — это только
|
||||
// бухпроводка без кошелькового движения, apply.cpp для неё walletop не диспатчит;
|
||||
// прямой вызов был бы no-op и сбил бы parity. Бывшие BLOCK(2)/UNBLOCK(3)/
|
||||
// BURN_BLOCKED(6) упразднены (2026-05-24): резерв возврата теперь TRANSFER на
|
||||
// w.wal.wpend, их op_code больше не валиден.
|
||||
eosio::check(op_code == static_cast<uint8_t>(WalletOp::ISSUE) ||
|
||||
op_code == static_cast<uint8_t>(WalletOp::TRANSFER) ||
|
||||
op_code == static_cast<uint8_t>(WalletOp::BURN),
|
||||
"walletop: недопустимый op_code");
|
||||
// op_code = 5 (NONE) намеренно не допускается: NONE-операции — это только
|
||||
// бухпроводка без кошелькового движения, apply.cpp не диспатчит для них walletop.
|
||||
// Прямой вызов с op_code=5 был бы no-op и сбил бы инвариант parity.
|
||||
// Допустимы 0..4 (ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN) и 6 (BURN_BLOCKED).
|
||||
eosio::check(op_code <= static_cast<uint8_t>(WalletOp::BURN_BLOCKED) &&
|
||||
op_code != static_cast<uint8_t>(WalletOp::NONE),
|
||||
"walletop: неизвестный op_code");
|
||||
|
||||
wallets2_index wallets(get_self(), coopname.value);
|
||||
userwallets_index user_wallets(get_self(), coopname.value);
|
||||
@@ -174,7 +172,7 @@ void ledger2::walletop(eosio::name coopname,
|
||||
// миграций 048/049.
|
||||
//
|
||||
// walletop по построению применяет одно и то же `amount` к L2 и L3 (см.
|
||||
// ниже case'ы ISSUE/TRANSFER/BURN), а sender-guard на
|
||||
// ниже case'ы ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN/BURN_BLOCKED), а sender-guard на
|
||||
// строке 46-47 запрещает обход. Поэтому инвариант сохраняется по
|
||||
// конструкции; полную сверку выполняет бэкенд («стол бухгалтера»),
|
||||
// вне транзакционного hot path.
|
||||
@@ -229,6 +227,60 @@ void ledger2::walletop(eosio::name coopname,
|
||||
cleanup_l3_if_empty(wallet_from);
|
||||
break;
|
||||
}
|
||||
case WalletOp::BLOCK: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BLOCK: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BLOCK: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->available >= amount,
|
||||
std::string{"walletop BLOCK: недостаточно available на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) {
|
||||
w.available -= amount;
|
||||
w.blocked += amount;
|
||||
});
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->available >= amount,
|
||||
std::string{"walletop BLOCK: недостаточно L3-available у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) {
|
||||
r.available -= amount;
|
||||
r.blocked += amount;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WalletOp::UNBLOCK: {
|
||||
eosio::check(wallet_from.value != 0, "walletop UNBLOCK: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop UNBLOCK: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->blocked >= amount,
|
||||
std::string{"walletop UNBLOCK: недостаточно blocked на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) {
|
||||
w.blocked -= amount;
|
||||
w.available += amount;
|
||||
});
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->blocked >= amount,
|
||||
std::string{"walletop UNBLOCK: недостаточно L3-blocked у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) {
|
||||
r.blocked -= amount;
|
||||
r.available += amount;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WalletOp::BURN: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BURN: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BURN: wallet_to должен быть пустым");
|
||||
@@ -259,6 +311,30 @@ void ledger2::walletop(eosio::name coopname,
|
||||
eosio::check(false, "walletop NONE: запрещённый op_code");
|
||||
break;
|
||||
}
|
||||
case WalletOp::BURN_BLOCKED: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BURN_BLOCKED: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BURN_BLOCKED: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->blocked >= amount,
|
||||
std::string{"walletop BURN_BLOCKED: недостаточно blocked на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) { w.blocked -= amount; });
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->blocked >= amount,
|
||||
std::string{"walletop BURN_BLOCKED: недостаточно L3-blocked у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) { r.blocked -= amount; });
|
||||
}
|
||||
|
||||
cleanup_l2_if_empty(wallet_from);
|
||||
cleanup_l3_if_empty(wallet_from);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-mutation Σ L3 == L2: см. блок выше (строки 163-175). Снято
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Универсальное миграционное действие контракта ledger2 — точка
|
||||
* расширения для разовых исправлений состояния, которые можно провести
|
||||
@@ -7,33 +9,23 @@
|
||||
* после её прогона на проде тело очищается до пустого `require_auth(get_self())`
|
||||
* (как в `capital::migrate`).
|
||||
*
|
||||
* Текущая задача (2026-05-24): свёртка `blocked → available` по ВСЕМ коопам.
|
||||
* Текущая задача (2026-05-21): чистка осиротевших L3-записей `w.cap.gen`
|
||||
* на voskhod после смены WalletKind GENERATOR_FUND с USER_SHARED на
|
||||
* COOPERATIVE (см. `lib/core/ledger2/wallets.hpp`).
|
||||
*
|
||||
* Контекст: механика «заблокированного» баланса упразднена (см.
|
||||
* `lib/core/ledger2/operations.hpp` — удалены WalletOp BLOCK/UNBLOCK/BURN_BLOCKED;
|
||||
* резерв возврата паевого теперь выражается переводом на кошелёк-резерв
|
||||
* `w.wal.wpend`). Поле `blocked` остаётся в таблицах `wallets2`/`userwallets`
|
||||
* как deprecated (физическое удаление поля = небезопасная смена layout таблицы
|
||||
* на живых коопах, выносится в отдельный cleanup-деплой). Перед тем как поле
|
||||
* перестанет поддерживаться кодом, накопленные `blocked`-остатки нужно вернуть
|
||||
* в `available`, чтобы средства не «зависли» на упразднённом субсчёте.
|
||||
* Контекст: на voskhod `convertsegm` падал «недостаточно L3-средств у
|
||||
* пайщика» в проектах, где CRPS перераспределял доли между сегментами.
|
||||
* w.cap.gen был USER_SHARED, а CRPS в approvecmmt не делал per-user
|
||||
* компенсирующих TRANSFER между сегментами: `Σ COMMIT_RID == Σ ACCEPT_RID`
|
||||
* соблюдался только на проекте, не на пайщике.
|
||||
*
|
||||
* Действие: пройти всех кооперативов (cooperatives2 в scope registrator) и для
|
||||
* каждого свернуть `blocked → available` на уровнях L2 (`wallets2`) и L3
|
||||
* (`userwallets`): `available += blocked; blocked = 0`. Сумма средств на кошельке
|
||||
* не меняется — только субсчёт.
|
||||
* Архитектурный фикс: w.cap.gen — COOPERATIVE-пул без L3. Чтобы UI/бэкенд
|
||||
* не врали остатками по «личным» w.cap.gen, удаляем осиротевшие L3-записи
|
||||
* прямым `userwallets.erase`. L2-баланс `wallets2[w.cap.gen]` уже верен
|
||||
* (синхронен с Σ старых userwallets), его не трогаем.
|
||||
*
|
||||
* Идемпотентно: после свёртки `blocked == 0`, повторный прогон — no-op.
|
||||
*
|
||||
* Сигнатура без аргументов — действие вызывается автоматически при деплое
|
||||
* контракта (как и прочие задачи migrate); проходит по всем кооперативам сам.
|
||||
*
|
||||
* ПРЕДУСЛОВИЕ (операционное): на момент прогона не должно быть заявок на возврат
|
||||
* «в полёте» (статусы pending/authorized в `wallet::withdraws`) — их `blocked`
|
||||
* относится к старой механике и при свёртке в `available` вернётся пайщику как
|
||||
* свободные средства, а последующий `completewthd` (BURN с `w.wal.wpend`) не
|
||||
* найдёт резерва. Незавершённые заявки нужно довести (complete/decline) ДО
|
||||
* деплоя с этой миграцией.
|
||||
* Идемпотентно: повторный вызов на чистой БД — no-op (lower_bound пуст).
|
||||
* Только voskhod: на остальных кооперативах w.cap.gen не использовался.
|
||||
*
|
||||
* @ingroup public_ledger2_actions
|
||||
*
|
||||
@@ -42,31 +34,24 @@
|
||||
void ledger2::migrate() {
|
||||
require_auth(get_self());
|
||||
|
||||
cooperatives2_index coops(_registrator, _registrator.value);
|
||||
const eosio::name target_coop = "voskhod"_n;
|
||||
|
||||
for (auto c = coops.begin(); c != coops.end(); ++c) {
|
||||
const eosio::name coopname = c->username;
|
||||
userwallets_index user_wallets(get_self(), target_coop.value);
|
||||
auto idx = user_wallets.get_index<"bywallet"_n>();
|
||||
|
||||
// --- L3: userwallets[coopname] ---
|
||||
// Модифицируем только не-ключевые поля (available/blocked) — итерация по
|
||||
// первичному индексу с modify безопасна (порядок строк не меняется).
|
||||
userwallets_index user_wallets(get_self(), coopname.value);
|
||||
for (auto it = user_wallets.begin(); it != user_wallets.end(); ++it) {
|
||||
if (it->blocked.amount <= 0) continue;
|
||||
user_wallets.modify(it, get_self(), [&](auto& r) {
|
||||
r.available += r.blocked;
|
||||
r.blocked = eosio::asset(0, r.blocked.symbol);
|
||||
});
|
||||
}
|
||||
// Собираем primary id записей до erase (модификация контейнера при
|
||||
// итерации через secondary index — небезопасна).
|
||||
std::vector<uint64_t> ids_to_erase;
|
||||
for (auto it = idx.lower_bound(ledger2_wallets::GENERATOR_FUND.value);
|
||||
it != idx.end() && it->wallet_name == ledger2_wallets::GENERATOR_FUND;
|
||||
++it) {
|
||||
ids_to_erase.push_back(it->id);
|
||||
}
|
||||
|
||||
// --- L2: wallets2[coopname] ---
|
||||
wallets2_index wallets(get_self(), coopname.value);
|
||||
for (auto it = wallets.begin(); it != wallets.end(); ++it) {
|
||||
if (it->blocked.amount <= 0) continue;
|
||||
wallets.modify(it, get_self(), [&](auto& w) {
|
||||
w.available += w.blocked;
|
||||
w.blocked = eosio::asset(0, w.blocked.symbol);
|
||||
});
|
||||
for (uint64_t id : ids_to_erase) {
|
||||
auto pri = user_wallets.find(id);
|
||||
if (pri != user_wallets.end()) {
|
||||
user_wallets.erase(pri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
#ifdef IS_TESTNET
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 1; /*!< минимальное количество членов совета (тест) */
|
||||
#else
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 3; /*!< минимальное количество членов совета (прод) */
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 5; /*!< минимальное количество членов совета (прод) */
|
||||
#endif
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
* - `WalletOp::WALLET_ONLY` удалён (ADR-003): «без бухпроводок» определяется
|
||||
* парой `(debit_account_id == 0, credit_account_id == 0)` на уровне записи.
|
||||
* Для `o.cap.invest` теперь TRANSFER без проводок (оба account_id == 0).
|
||||
* - `WalletOp::BURN` (ADR-003): `available -= amount` на `wallet_from`,
|
||||
* без `wallet_to`. Используется в `o.wal.wthcpl` (сжигание резерва возврата
|
||||
* с `w.wal.wpend`), `o.cap.drppre` и как зеркало ISSUE в `revert`.
|
||||
* - Добавлен `WalletOp::BURN` (ADR-003): `available -= amount` на `wallet_from`,
|
||||
* без `wallet_to`. На текущем этапе в `OPERATION_REGISTRY` не используется —
|
||||
* зарезервирован под будущие операции штатного сжигания.
|
||||
* - Единые программные кошельки: `BLAGOROST_FUND` (`w.cap.blago`) и
|
||||
* `GENERATOR_FUND` (`w.cap.gen`) — заменили ранее декомпозированные
|
||||
* `bginv/bgprop/bgrid/bgmem` и `gncom/gnmem` (ADR-009).
|
||||
@@ -61,9 +61,9 @@ namespace operations {
|
||||
// wallet
|
||||
namespace wallet {
|
||||
inline constexpr eosio::name COMPLETE_DEPOSIT = "o.wal.depcpl"_n; ///< Завершение внесения паевого взноса (Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY).
|
||||
inline constexpr eosio::name COMPLETE_WITHDRAW = "o.wal.wthcpl"_n; ///< Завершение возврата паевого взноса (Dr 80 / Cr 51, BURN с WITHDRAW_PENDING — деньги уходят из системы, без wallet_to).
|
||||
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: TRANSFER SHARE_FUND_PAY → WITHDRAW_PENDING (резерв, без Dr/Cr).
|
||||
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: TRANSFER WITHDRAW_PENDING → SHARE_FUND_PAY (снятие резерва, без Dr/Cr).
|
||||
inline constexpr eosio::name COMPLETE_WITHDRAW = "o.wal.wthcpl"_n; ///< Завершение возврата паевого взноса (Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY — деньги уходят из системы, без wallet_to).
|
||||
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: BLOCK на SHARE_FUND_PAY (без Dr/Cr).
|
||||
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: UNBLOCK на SHARE_FUND_PAY (без Dr/Cr).
|
||||
}
|
||||
|
||||
// capital
|
||||
@@ -129,23 +129,14 @@ namespace operations {
|
||||
* `(debit_account_id == 0, credit_account_id == 0)` на уровне записи реестра.
|
||||
* Compile-time правило `(debit==0) ⇔ (credit==0)` ловит смешанные пары.
|
||||
*/
|
||||
//
|
||||
// Удаление BLOCK/UNBLOCK/BURN_BLOCKED (2026-05-24): механика «заблокированного»
|
||||
// баланса упразднена. Резерв средств под заявку на возврат паевого теперь
|
||||
// выражается переводом на отдельный кошелёк-резерв `w.wal.wpend` (TRANSFER),
|
||||
// возврат резерва — обратным TRANSFER, завершение — BURN с резерва. Поле
|
||||
// `blocked` в таблицах wallets2/userwallets оставлено deprecated (всегда 0
|
||||
// после ledger2::migrate-свёртки) — физическое удаление поля = небезопасная
|
||||
// смена layout таблицы на живых коопах, выносится в отдельный cleanup-деплой.
|
||||
//
|
||||
// Числовые значения ISSUE/TRANSFER/BURN/NONE СОХРАНЕНЫ (не перенумерованы),
|
||||
// чтобы исторические op_code в blockchain_actions читались бэкендом без сдвига
|
||||
// смысла (2 и 3 — бывшие BLOCK/UNBLOCK — больше не выдаются и невалидны на входе).
|
||||
enum class WalletOp : uint8_t {
|
||||
ISSUE = 0, ///< первичный вход средств на кошелёк wallet_to (wallet_from = empty)
|
||||
TRANSFER = 1, ///< перемещение wallet_from → wallet_to (с Dr/Cr ИЛИ без — по парам account_id)
|
||||
BLOCK = 2, ///< available-=amount, blocked+=amount на wallet_from
|
||||
UNBLOCK = 3, ///< blocked-=amount, available+=amount на wallet_from
|
||||
BURN = 4, ///< изъятие amount с wallet_from->available, без wallet_to. Покрывает оба кейса: (a) штатное сжигание как бизнес-операция в OPERATION_REGISTRY; (b) зеркало ISSUE при `ledger2::revert` (различие — через operation_code: `o.adj.rev` для adjustment-mirror).
|
||||
NONE = 5, ///< только бухпроводка без перемещения средств (wallet_from = empty, wallet_to = empty, debit ≠ 0, credit ≠ 0). Покрывает кейсы внутрибалансовых проводок типа Dr 04 / Cr 08 (приём РИД в НМА), когда кошелёк уже на нужном программном фонде.
|
||||
BURN_BLOCKED = 6, ///< изъятие amount из wallet_from->blocked, без wallet_to. Используется при завершении возврата паевого (`o.wal.wthcpl`): средства предварительно заблокированы через BLOCK (REQUEST_WITHDRAW), на завершении сжигаются — выходят из системы (получателя на цепи нет). На L3 синхронно: from_uw->blocked -= amount.
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -154,7 +145,7 @@ enum class WalletOp : uint8_t {
|
||||
* Семантика полей по `wallet_op`:
|
||||
* - ISSUE: wallet_from = eosio::name{}, wallet_to = required.
|
||||
* - TRANSFER: wallet_from = required, wallet_to = required (≠ from).
|
||||
* - BURN: wallet_from = required, wallet_to = eosio::name{}.
|
||||
* - BLOCK / UNBLOCK / BURN / BURN_BLOCKED: wallet_from = required, wallet_to = eosio::name{}.
|
||||
*
|
||||
* Семантика бух.проводки:
|
||||
* - Без проводок: debit_account_id == 0 И credit_account_id == 0.
|
||||
@@ -166,7 +157,7 @@ struct OperationRegistryEntry {
|
||||
eosio::name process_type; ///< тип процесса с префиксом `p.<contract>.<noun>`
|
||||
WalletOp wallet_op;
|
||||
eosio::name wallet_from; ///< пустое имя для ISSUE
|
||||
eosio::name wallet_to; ///< пустое имя для BURN
|
||||
eosio::name wallet_to; ///< пустое имя для BLOCK/UNBLOCK/BURN/BURN_BLOCKED
|
||||
uint64_t debit_account_id; ///< 0 если без бухпроводки (тогда credit_account_id тоже == 0)
|
||||
uint64_t credit_account_id; ///< 0 если без бухпроводки (тогда debit_account_id тоже == 0)
|
||||
const char* human_name;
|
||||
@@ -193,12 +184,12 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
"Внесение пайщиком паевого взноса" },
|
||||
|
||||
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN WITHDRAW_PENDING.
|
||||
// Сжигание из кошелька-резерва (TRANSFER в резерв был на REQUEST_WITHDRAW):
|
||||
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY.
|
||||
// Сжигание из заблокированной суммы пайщика (BLOCK был на REQUEST_WITHDRAW):
|
||||
// деньги уходят из системы (банковский перевод пайщику), получателя на цепи нет.
|
||||
// Бухгалтерия: паевой фонд уменьшается (Дт 80), расчётный счёт уменьшается (Кт 51).
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN,
|
||||
ledger2_wallets::WITHDRAW_PENDING, eosio::name{},
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN_BLOCKED,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат паевого взноса пайщику" },
|
||||
|
||||
@@ -282,19 +273,17 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::TARGET_RECEIPTS,
|
||||
"Трансляция паевого взноса из ЦПП «Цифровой Кошелёк» в членский взнос за пользование инфраструктурой" },
|
||||
|
||||
// 15. Запрос на возврат паевого: TRANSFER SHARE_FUND_PAY → WITHDRAW_PENDING
|
||||
// (без Dr/Cr — оба кошелька на счёте 80; резерв средств на время рассмотрения).
|
||||
{ operations::wallet::REQUEST_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::SHARE_FUND_PAY, ledger2_wallets::WITHDRAW_PENDING,
|
||||
// 15. Запрос на возврат паевого: BLOCK SHARE_FUND_PAY (без Dr/Cr — внутри одного бухсчёта 80).
|
||||
{ operations::wallet::REQUEST_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BLOCK,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
0, 0,
|
||||
"Резервирование паевого под запрос на возврат" },
|
||||
"Блокировка паевого под запрос на возврат" },
|
||||
|
||||
// 16. Отклонение запроса на возврат: TRANSFER WITHDRAW_PENDING → SHARE_FUND_PAY
|
||||
// (без Dr/Cr — зеркало REQUEST_WITHDRAW; возврат резерва пайщику).
|
||||
{ operations::wallet::DECLINE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::WITHDRAW_PENDING, ledger2_wallets::SHARE_FUND_PAY,
|
||||
// 16. Отклонение запроса на возврат: UNBLOCK SHARE_FUND_PAY (без Dr/Cr — зеркало REQUEST_WITHDRAW).
|
||||
{ operations::wallet::DECLINE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::UNBLOCK,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
0, 0,
|
||||
"Снятие резерва паевого после отклонения запроса на возврат" },
|
||||
"Разблокировка паевого после отклонения запроса на возврат" },
|
||||
|
||||
// 17. Возврат из ЦПП «Благорост» в Цифровой Кошелёк: TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY (без Dr/Cr — оба счёта 80, зеркало INVEST).
|
||||
{ operations::capital::WITHDRAW_FROM_CAPITAL, processes::capital::WTHCAP, WalletOp::TRANSFER,
|
||||
@@ -351,7 +340,7 @@ static constexpr size_t OPERATION_REGISTRY_SIZE = sizeof(OPERATION_REGISTRY) / s
|
||||
// оба существуют в `LEDGER2_ACCOUNT_MAP`.
|
||||
// 4. Для TRANSFER: `wallet_from` ≠ `wallet_to`, оба ≠ 0.
|
||||
// 5. Для ISSUE: `wallet_from` == 0 и `wallet_to` ≠ 0.
|
||||
// 6. Для BURN: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 6. Для BLOCK / UNBLOCK / BURN / BURN_BLOCKED: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 7. Все id кошельков из записей существуют в `LEDGER2_WALLET_REGISTRY`.
|
||||
namespace ledger2_registry_detail {
|
||||
constexpr bool operation_codes_unique() {
|
||||
@@ -395,11 +384,11 @@ namespace ledger2_registry_detail {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Правило 6: BURN — wallet_from required, wallet_to == 0 (ADR-003).
|
||||
// Правило 6: BURN / BURN_BLOCKED — wallet_from required, wallet_to == 0 (ADR-003).
|
||||
constexpr bool burn_pattern_correct() {
|
||||
for (size_t i = 0; i < OPERATION_REGISTRY_SIZE; ++i) {
|
||||
const auto& e = OPERATION_REGISTRY[i];
|
||||
if (e.wallet_op != WalletOp::BURN) continue;
|
||||
if (e.wallet_op != WalletOp::BURN && e.wallet_op != WalletOp::BURN_BLOCKED) continue;
|
||||
if (e.wallet_from.value == 0) return false;
|
||||
if (e.wallet_to.value != 0) return false;
|
||||
}
|
||||
@@ -451,7 +440,7 @@ static_assert(ledger2_registry_detail::dr_ne_cr_when_posting(),
|
||||
static_assert(ledger2_registry_detail::transfer_wallet_from_ne_to(),
|
||||
"OPERATION_REGISTRY: TRANSFER с wallet_from == wallet_to или одним из них == 0");
|
||||
static_assert(ledger2_registry_detail::burn_pattern_correct(),
|
||||
"OPERATION_REGISTRY: BURN требует wallet_from ≠ 0 и wallet_to == 0");
|
||||
"OPERATION_REGISTRY: BURN / BURN_BLOCKED требует wallet_from ≠ 0 и wallet_to == 0");
|
||||
static_assert(ledger2_registry_detail::none_pattern_correct(),
|
||||
"OPERATION_REGISTRY: NONE требует wallet_from == 0, wallet_to == 0 и обе проводки заполненными");
|
||||
static_assert(ledger2_registry_detail::accounts_exist_in_map(),
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* w.mkt.* — Маркетплейс (выплаты поставщикам)
|
||||
*
|
||||
* Sentinel `eosio::name{}` (пустое имя, value=0) — «кошелёк вне системы»
|
||||
* для ISSUE (нет wallet_from) и для BURN (нет wallet_to).
|
||||
* для ISSUE (нет wallet_from) и для BURN/BLOCK/UNBLOCK (нет wallet_to).
|
||||
*
|
||||
* При первом ISSUE/TRANSFER кошелёк создаётся автоматически по записи
|
||||
* из WALLET_REGISTRY. При обнулении available+blocked запись удаляется.
|
||||
@@ -46,8 +46,7 @@ struct ledger2_wallets {
|
||||
// wallet — паевой фонд + возвраты + ЦК
|
||||
static constexpr eosio::name SHARE_FUND_PAY = "w.wal.share"_n; ///< Паевой взнос пайщика (USER_SHARED)
|
||||
static constexpr eosio::name CK_MEMBER = "w.wal.member"_n; ///< ЦК — членская часть пайщика (USER_SHARED)
|
||||
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: исторический sink возвратов. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
|
||||
static constexpr eosio::name WITHDRAW_PENDING = "w.wal.wpend"_n; ///< Резерв паевого под заявку на возврат (COOPERATIVE-пул). o.wal.wthreq переводит сюда с w.wal.share, o.wal.wthdec возвращает обратно, o.wal.wthcpl сжигает отсюда. Заменил механику blocked/BLOCK/UNBLOCK 2026-05-24.
|
||||
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: после переключения o.wal.wthcpl с TRANSFER на BURN_BLOCKED кошелёк больше не получает новых средств. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
|
||||
|
||||
// registrator — минимальный паевой + вступительные
|
||||
static constexpr eosio::name MIN_SHARE_FUND = "w.reg.minshr"_n; ///< Минимальный паевой взнос пайщика (USER_SHARED, без сверки соглашений)
|
||||
@@ -95,7 +94,7 @@ struct Ledger2WalletMeta {
|
||||
WalletKind kind;
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
|
||||
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (5) — L3-разрез по пайщику
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
|
||||
@@ -103,7 +102,7 @@ inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
|
||||
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
|
||||
|
||||
// COOPERATIVE (10) — единый кооперативный баланс, без L3
|
||||
// COOPERATIVE (9) — единый кооперативный баланс, без L3
|
||||
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
|
||||
// CRPS-распределение между сегментами проекта не поддерживает per-user
|
||||
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
|
||||
@@ -111,7 +110,6 @@ inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
|
||||
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам (deprecated, не используется в новых операциях)", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAW_PENDING, "Резерв паевого под заявку на возврат", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::DELEGATE_FEES, "Делегатские членские взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::SOV_EXPENSES, "Хозяйственные расходы из числа целевого финансирования", WalletKind::COOPERATIVE },
|
||||
|
||||
@@ -9,14 +9,10 @@
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_soviet_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
* @note Авторизация требуется от аккаунта: @p chairman
|
||||
*/
|
||||
void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t decision_id, document2 document) {
|
||||
// Авторизует кооператив (действие проводится через бэкенд ключом кооператива).
|
||||
// Согласие председателя при этом не теряется: оно подтверждается криптографически
|
||||
// его личной подписью на документе (verify_document_or_fail ниже) и проверкой
|
||||
// is_valid_chairman — без валидного подписанного председателем документа авторизация невозможна.
|
||||
require_auth(coopname);
|
||||
void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t decision_id, document2 document) {
|
||||
require_auth(chairman);
|
||||
|
||||
boards_index boards(_soviet, coopname.value);
|
||||
autosigner_index autosigner(_soviet, coopname.value);
|
||||
@@ -31,9 +27,7 @@ void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t deci
|
||||
|
||||
verify_document_or_fail(document);
|
||||
|
||||
// RAM-плательщик — кооператив: транзакцию авторизует coopname (require_auth выше),
|
||||
// поэтому увеличивать RAM аккаунта председателя нельзя (он tx не подписывал).
|
||||
decisions.modify(decision, coopname, [&](auto &d){
|
||||
decisions.modify(decision, chairman, [&](auto &d){
|
||||
d.authorized_by = chairman;
|
||||
d.authorized = !decision -> authorized;
|
||||
d.authorization = document;
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
* @param coopname Имя кооператива
|
||||
* @param decision_id Идентификатор решения для исполнения
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
* @note Авторизация требуется от аккаунта: @p executer
|
||||
*/
|
||||
void soviet::exec(eosio::name executer, eosio::name coopname, uint64_t decision_id) {
|
||||
// Исполнение проводится через бэкенд ключом кооператива. Параметр executer остаётся
|
||||
// в payload как аудит-метка (кто инициировал исполнение) и не используется для авторизации
|
||||
// inline-эффектов — те выполняются под собственными правами контракта (_soviet/_gateway/_fund).
|
||||
require_auth(coopname);
|
||||
|
||||
void soviet::exec(eosio::name executer, eosio::name coopname, uint64_t decision_id) {
|
||||
require_auth(executer);
|
||||
|
||||
decisions_index decisions(_soviet, coopname.value);
|
||||
auto decision = decisions.find(decision_id);
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ void soviet::make_base_coagreements( eosio::name coopname, eosio::symbol govern_
|
||||
row.type = "user"_n;
|
||||
row.coopname = coopname;
|
||||
row.program_id = 0;
|
||||
row.draft_id = 4;
|
||||
row.draft_id = 3;
|
||||
});
|
||||
|
||||
//создаём соглашение для шаблона политики конфиденциальности
|
||||
@@ -132,7 +132,7 @@ void soviet::make_base_coagreements( eosio::name coopname, eosio::symbol govern_
|
||||
row.type = "privacy"_n;
|
||||
row.coopname = coopname;
|
||||
row.program_id = 0;
|
||||
row.draft_id = 3;
|
||||
row.draft_id = 4;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
# программу «Цифровой Кошелёк» (SHARE_FUND_PAY).
|
||||
#
|
||||
# Четырёхактовый процесс с одной авторизацией советом. Запись заявки живёт в
|
||||
# таблице `withdraws` от создания до выплаты или отказа. На подаче заявки сумма
|
||||
# переводится в кошелёк-резерв (WITHDRAW_PENDING, w.wal.wpend). На закрывающем
|
||||
# действии completewthd срабатывает операция o.wal.wthcpl — сжигание суммы из
|
||||
# резерва (BURN w.wal.wpend): деньги уходят из системы пайщику банковским
|
||||
# переводом, получателя на цепи нет.
|
||||
# таблице `withdraws` от создания до выплаты или отказа. На закрывающем
|
||||
# действии completewthd срабатывает одна операция o.wal.wthcpl — сжигание
|
||||
# заблокированной суммы (BURN_BLOCKED w.wal.share): деньги уходят из системы
|
||||
# пайщику банковским переводом, получателя на цепи нет.
|
||||
#
|
||||
# Источники правды в коде:
|
||||
# • cpp/wallet/wallet.hpp — actions
|
||||
@@ -44,8 +43,8 @@ actions:
|
||||
actor: contributor
|
||||
role: opener
|
||||
purpose: >
|
||||
Пайщик создаёт заявку на возврат паевого взноса. Контракт переводит
|
||||
сумму с кошелька SHARE_FUND_PAY в кошелёк-резерв WITHDRAW_PENDING и
|
||||
Пайщик создаёт заявку на возврат паевого взноса. Контракт блокирует
|
||||
сумму на кошельке SHARE_FUND_PAY (статус available → blocked) и
|
||||
создаёт повестку в совете о возврате с callback'ом авторизации.
|
||||
- name: wallet::authwthd
|
||||
human: Авторизовать выплату
|
||||
@@ -61,17 +60,16 @@ actions:
|
||||
role: closer
|
||||
purpose: >
|
||||
Gateway подтвердил списание средств в пользу пайщика. Это закрывающее
|
||||
действие: применяется операция o.wal.wthcpl (BURN w.wal.wpend)
|
||||
— сумма из резерва сжигается, в бухгалтерии проходит обратная
|
||||
действие: применяется операция o.wal.wthcpl (BURN_BLOCKED w.wal.share)
|
||||
— заблокированная сумма сжигается, в бухгалтерии проходит обратная
|
||||
проводка Дт 80 / Кт 51. Запись заявки удаляется.
|
||||
- name: wallet::declinewthd
|
||||
human: Отклонить
|
||||
actor: soviet
|
||||
role: reject
|
||||
purpose: >
|
||||
Отказ на любом из этапов до выплаты. Сумма возвращается из резерва на
|
||||
кошелёк пайщика (o.wal.wthdec: WITHDRAW_PENDING → SHARE_FUND_PAY),
|
||||
запись заявки удаляется.
|
||||
Отказ на любом из этапов до выплаты. Сумма разблокируется на кошельке
|
||||
пайщика (UNBLOCK через o.wal.wthdec), запись заявки удаляется.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: wallet::withdraw
|
||||
@@ -82,8 +80,8 @@ states:
|
||||
- name: pending
|
||||
human: Ожидает решения совета
|
||||
description: >
|
||||
Заявка создана, сумма переведена в кошелёк-резерв (SHARE_FUND_PAY →
|
||||
WITHDRAW_PENDING), повестка отправлена в совет.
|
||||
Заявка создана, сумма заблокирована на кошельке пайщика (available → blocked),
|
||||
повестка отправлена в совет.
|
||||
kind: normal
|
||||
- name: authorized
|
||||
human: Выплата отправлена
|
||||
@@ -94,15 +92,15 @@ states:
|
||||
- name: completed
|
||||
human: Возврат выплачен
|
||||
description: >
|
||||
Gateway подтвердил списание, сумма из резерва сжигается
|
||||
(BURN w.wal.wpend). В бухгалтерии прошла обратная
|
||||
Gateway подтвердил списание, заблокированная сумма сжигается на кошельке
|
||||
пайщика (BURN_BLOCKED w.wal.share). В бухгалтерии прошла обратная
|
||||
проводка Дт 80 / Кт 51. Запись заявки удалена.
|
||||
kind: final
|
||||
- name: removed
|
||||
human: Отклонено
|
||||
description: >
|
||||
Возврат не состоялся: сумма возвращена из резерва на кошелёк пайщика
|
||||
(o.wal.wthdec: WITHDRAW_PENDING → SHARE_FUND_PAY), запись удалена.
|
||||
Возврат не состоялся: сумма разблокирована (UNBLOCK через o.wal.wthdec),
|
||||
запись удалена.
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
@@ -174,7 +172,7 @@ scenario:
|
||||
- Сумма возврата ≤ доступного остатка.
|
||||
post:
|
||||
- В таблице withdraws создана запись со статусом `pending`.
|
||||
- Сумма переведена с SHARE_FUND_PAY в кошелёк-резерв WITHDRAW_PENDING.
|
||||
- На кошельке пайщика сумма переведена available → blocked.
|
||||
- Совет получил повестку о возврате.
|
||||
|
||||
- step: 2
|
||||
@@ -199,13 +197,13 @@ scenario:
|
||||
action: wallet::completewthd
|
||||
description: >
|
||||
Gateway подтверждает списание средств в пользу пайщика. Контракт
|
||||
применяет ledger2-операцию o.wal.wthcpl (BURN w.wal.wpend с
|
||||
применяет ledger2-операцию o.wal.wthcpl (BURN_BLOCKED w.wal.share с
|
||||
обратной проводкой Дт 80 / Кт 51) и удаляет запись заявки.
|
||||
pre:
|
||||
- Заявка в статусе `authorized`.
|
||||
- Gateway подтвердил списание.
|
||||
post:
|
||||
- Сумма из резерва сожжена (WITHDRAW_PENDING, w.wal.wpend).
|
||||
- Заблокированная сумма сожжена с SHARE_FUND_PAY (w.wal.share).
|
||||
- В ledger2 применена операция o.wal.wthcpl.
|
||||
- Запись заявки удалена.
|
||||
|
||||
@@ -216,8 +214,8 @@ scenario:
|
||||
actor: soviet
|
||||
description: >
|
||||
Совет принял отрицательное решение либо Gateway отклонил платёж.
|
||||
Сумма возвращена из резерва на кошелёк пайщика (o.wal.wthdec:
|
||||
WITHDRAW_PENDING → SHARE_FUND_PAY), запись удалена.
|
||||
Сумма разблокирована на кошельке пайщика (UNBLOCK через o.wal.wthdec),
|
||||
запись удалена.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
@@ -236,45 +234,45 @@ documents:
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
operations:
|
||||
- ledger_code: o.wal.wthreq
|
||||
human_name: Резервирование паевого под запрос на возврат
|
||||
wallet_op: TRANSFER
|
||||
human_name: Блокировка паевого под запрос на возврат
|
||||
wallet_op: BLOCK
|
||||
wallet_from: w.wal.share
|
||||
wallet_to: w.wal.wpend
|
||||
wallet_to: ~
|
||||
debit: ~
|
||||
credit: ~
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::createwthd
|
||||
description: >
|
||||
На создании заявки контракт переводит сумму с кошелька пайщика
|
||||
(SHARE_FUND_PAY) в кошелёк-резерв (WITHDRAW_PENDING). Бухгалтерская
|
||||
проводка не создаётся — движение внутри одного бухсчёта 80.
|
||||
На создании заявки контракт блокирует сумму на кошельке пайщика
|
||||
(SHARE_FUND_PAY): available → blocked. Бухгалтерская проводка не
|
||||
создаётся — движение внутри одного бухсчёта 80.
|
||||
|
||||
- ledger_code: o.wal.wthdec
|
||||
human_name: Снятие резерва паевого после отклонения запроса на возврат
|
||||
wallet_op: TRANSFER
|
||||
wallet_from: w.wal.wpend
|
||||
wallet_to: w.wal.share
|
||||
human_name: Разблокировка паевого после отклонения запроса на возврат
|
||||
wallet_op: UNBLOCK
|
||||
wallet_from: w.wal.share
|
||||
wallet_to: ~
|
||||
debit: ~
|
||||
credit: ~
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::declinewthd
|
||||
description: >
|
||||
Зеркало REQUEST_WITHDRAW: сумма возвращается из резерва (WITHDRAW_PENDING)
|
||||
на кошелёк пайщика (SHARE_FUND_PAY). Бухгалтерская проводка не создаётся.
|
||||
Зеркало REQUEST_WITHDRAW: blocked → available на кошельке пайщика.
|
||||
Бухгалтерская проводка не создаётся.
|
||||
|
||||
- ledger_code: o.wal.wthcpl
|
||||
human_name: Возврат паевого взноса пайщику
|
||||
wallet_op: BURN
|
||||
wallet_from: w.wal.wpend # кошелёк-резерв возвратов
|
||||
wallet_op: BURN_BLOCKED
|
||||
wallet_from: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
|
||||
wallet_to: ~ # деньги уходят из системы, получателя на цепи нет
|
||||
debit: 80 # Паевой фонд (складочный капитал)
|
||||
credit: 51 # Расчётный счёт
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::completewthd
|
||||
description: >
|
||||
Обратная операция к o.wal.depcpl: зарезервированная сумма сжигается с
|
||||
кошелька-резерва (w.wal.wpend) — деньги уходят из системы банковским
|
||||
переводом пайщику. На цепи получателя нет.
|
||||
Обратная операция к o.wal.depcpl: заблокированная сумма сжигается с
|
||||
кошелька пайщика «ЦПП Цифровой Кошелёк» (w.wal.share) — деньги уходят
|
||||
из системы банковским переводом пайщику. На цепи получателя нет.
|
||||
В бухгалтерии прошла проводка Дт 80 / Кт 51 — паевой фонд уменьшился,
|
||||
деньги ушли с расчётного счёта пайщику.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.5.30-alpha-2",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -71,6 +71,12 @@ SENTRY_DSN=
|
||||
GITHUB_TOKEN=
|
||||
|
||||
|
||||
# OpenSearch (поисковая система документов, опционально)
|
||||
OPENSEARCH_NODE=https://opensearch:9200
|
||||
OPENSEARCH_ENABLED=false
|
||||
OPENSEARCH_USERNAME=admin
|
||||
OPENSEARCH_PASSWORD=
|
||||
|
||||
LIVEKIT_URL=
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
|
||||
@@ -58,6 +58,7 @@ _Критичные правила и паттерны для AI-агентов
|
||||
### Language-Specific (TypeScript)
|
||||
|
||||
- **Bigint из PostgreSQL приходит как STRING.** Всегда `Number(blockNum) < Number(currentBlockNum)`, никогда не полагаться на `<`/`>` для `bigint`-колонок напрямую.
|
||||
- **block_num — разнобой типов в текущей кодовой базе (Story 4.3 audit).** `BaseTypeormEntity` (capital + shared) использует `@Column({ type: 'integer' }) block_num!: number` (PG возвращает number — корректно). Инфраструктурные entity (`ActionEntity`, `DeltaEntity`, `ForkEntity`, `SyncStateEntity`) используют `@Column({ type: 'bigint' }) block_num!: number` — **type-mismatch** (runtime будет string, TS говорит number). `ConsumerDedupEntity` — `bigint` + `string | null` (корректно). Это технический долг (Epic 9 backlog: bigint Transformer). Hot-path везде явно делает `Number()` либо использует TypeORM bind, так что runtime safe. При добавлении нового блокчейн-зеркала — выбрать одно из: (a) `integer` + `number`; (b) `bigint` + Transformer возвращающий `number`; (c) `bigint` + `string` с явным `Number()` в кодe. НЕ `bigint` + declaration `number` без Transformer.
|
||||
- **`Object.assign(this, blockchainData)` запрещено** в sync-сущностях — ломает типизацию. Только явное копирование полей.
|
||||
- **Lowercase hash полей (`project_hash`, `listing_hash`)** — нормализация **в конструкторе/mapValue**, НЕ в mapper'ах и НЕ повторно в `updateFromBlockchain`.
|
||||
- **Discriminated union для write-mutation response:** `{ status: 'applied' | 'pending' | 'failed' | 'conflict' }`. Клиент должен switch на статусе.
|
||||
@@ -76,6 +77,8 @@ _Критичные правила и паттерны для AI-агентов
|
||||
- `@Column({ type: 'bigint', nullable: true })` для `block_num`. Для `jsonb` — `@Column({ type: 'jsonb' })`.
|
||||
- `ADD COLUMN NOT NULL` на больших таблицах — **двухэтапно**: ADD nullable → backfill → ALTER NOT NULL.
|
||||
- Repository `extends BaseBlockchainRepository<DomainEntity, TypeormEntity>`. `findBySyncKey`, `createIfNotExists`, `deleteByBlockNumGreaterThan`, `restoreFromVersions` — **наследуются**, не реализовывать руками.
|
||||
- **Контракт «entity с block_num → repo extends BaseBlockchainRepository» (Story 4.3).** Любая `*.typeorm-entity.ts` extends `BaseTypeormEntity` ОБЯЗАНА иметь репозиторий extends `BaseBlockchainRepository` — иначе `entity_versions` не пишется (silent), а форк-rollback превращается в hard delete без восстановления. CI grep-guard: `tests/unit/blockchain/base-blockchain-repository.contract.test.ts`. Allowlist для 5 off-chain entity (`comment`, `cycle`, `issue`, `story`, `time-entry` — vestigial block_num, не блокчейн-зеркала). Новое исключение в allowlist — только с обоснованием в audit-report.
|
||||
- **Архив форка вместо hard-delete (Story 4.4).** `handleFork(N, eventId?)` НЕ удаляет live-ряды и снимки версий — переносит в `invalidated_entities` / `invalidated_entity_versions` (атомарно через DataSource.transaction). Порядок: archiveInvalidatedSince → restoreFromVersions → archiveInvalidatedVersionsSince. `fork_event_id` группирует записи одного форка (для forensic). Retention — `BlockchainArchiveRetentionService` ежечасно удаляет архив старше `LIB - 1000` блоков. LIB читается через `BlockchainService.getInfo()` (вариант C, RPC `/v1/chain/get_info`). Окно `RETENTION_HORIZON_BLOCKS = 1000` ХАРДКОД (свойство сети, не оператора). Env-переключатели: `BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED` (default true), `BLOCKCHAIN_ARCHIVE_RETENTION_CRON` (default `0 * * * *`).
|
||||
|
||||
**parser2 integration:**
|
||||
- `ParserClient` subscribe с `subscriptionId = "controller-${coopname}"`, `consumerName = "primary"` (детерминирован), `startFromBlock: 'last_known'`.
|
||||
@@ -87,14 +90,16 @@ _Критичные правила и паттерны для AI-агентов
|
||||
- `{contract}{Entity}Updated` / `Deleted` / `RolledBack` / `PendingRetry` / `Failed` — **pubsub канал** для GraphQL subscriptions. Per-contract, не global.
|
||||
- `entitysynced::{contract}::{table}` — для business-side-effect listeners (Matrix / Notification / и т.п.).
|
||||
|
||||
### Composite-Entity (ADR-008) — СТРОГО
|
||||
### Composite-Entity (ADR-008) — будущая цель, не сейчас
|
||||
|
||||
- Namespaced: `entity.db.X` (DB-поля) / `entity.bc?.Y` (blockchain, nullable) / `entity.derived.Z` (computed getters).
|
||||
- **НЕ** писать `entity.X` напрямую — ломает изоляцию.
|
||||
- Конструктор `(databaseData, blockchainData?)` — обязан `throw` на sync-key mismatch.
|
||||
- `updateFromBlockchain` возвращает **новый экземпляр** (immutable) или мутирует только `this.bc`, `this.block_num`, `this.present` — БЕЗ `Object.assign`.
|
||||
- `derived` getter — детерминирован (NO `new Date()` в конструкторе / getter — ломает snapshot tests).
|
||||
- Все signed-document поля нормализуются через `AbstractDeltaMapper.normalizeSignedDocuments` на основе `signedDocumentFields: SignedDocField[]` декларативно, НЕ руками в mapper.
|
||||
ADR-008 описывает целевой паттерн `entity.db.X` / `entity.bc?.Y` / `entity.derived.Z`, заменяющий
|
||||
`Object.assign(this, blockchainData)`. Переход вынесен за пределы MVP-релиза parser2 в отдельный
|
||||
sync-arch sanitation-эпик: blast radius на 22 entity + потребители «плоских» полей в resolver'ах
|
||||
делают эту миграцию большой и рискованной задачей, несвязанной с заменой транспорта parser1→parser2.
|
||||
|
||||
До отдельного эпика — текущий код продолжает использовать flat-namespace + `Object.assign` в
|
||||
`updateFromBlockchain`. Не вводить namespace частично на одной entity — двойной канон хуже единого
|
||||
старого.
|
||||
|
||||
### Dispatch pipeline (ADR-002, ADR-009) — СТРОГО
|
||||
|
||||
@@ -216,7 +221,6 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
- `BLOCKCHAIN_RECONCILE_CRON` default `'0 * * * *'`
|
||||
- `BLOCKCHAIN_RECONCILE_SAMPLE_SIZE` default 100
|
||||
- `BLOCKCHAIN_RECONCILE_TOLERANCE_BLOCKS` default 10
|
||||
- `BLOCKCHAIN_FORK_PAUSE_TIMEOUT_MS` default 30000
|
||||
- `BLOCKCHAIN_DLQ_MAX_RETRIES` default 5
|
||||
- `BLOCKCHAIN_MAX_TX_RETRIES` default 3
|
||||
- `BLOCKCHAIN_PENDING_TX_MAX_AGE_SECONDS` default 3600
|
||||
@@ -251,12 +255,13 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
- `await capitalBlockchainPort.getProject(hash)` в resolver / application — **retire**. Только `repository.findBySyncKey`.
|
||||
- RPC fallback "если PG не отдал" — **запрещено**. Если PG null → pending status наверх.
|
||||
|
||||
### ❌ Composite-entity anti-patterns
|
||||
### ❌ Domain-entity anti-patterns
|
||||
|
||||
- `project.matrix_room_id` (flat access) — **запрещено**. Правильно: `project.db.matrix_room_id`.
|
||||
- `project.master` (flat access) — **запрещено**. Правильно: `project.bc?.master`.
|
||||
- `Object.assign(this, blockchainData)` в update — **запрещено**.
|
||||
- Новый `new Date()` в конструкторе или derived-getter — **запрещено** (ломает snapshot-tests).
|
||||
- Миграция на namespace `entity.db.X` / `entity.bc?.Y` — целевой паттерн ADR-008, но отложен
|
||||
в отдельный sync-arch sanitation-эпик: текущий код всё ещё использует flat-namespace +
|
||||
`Object.assign(this, blockchainData)` в `updateFromBlockchain`. Не разводить два стандарта
|
||||
частично.
|
||||
|
||||
### ❌ Fork handling anti-patterns
|
||||
|
||||
@@ -296,7 +301,6 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- `JSON.stringify` на сущностях с nested signed-documents — использовать `json-stable-stringify` для canonical checksum.
|
||||
- Reconciliation cron — sample N=100 rows, **не** full scan на hot path.
|
||||
- IPFS fetch для signed-doc — **lazy resolver вне consumer critical path**, не в mapper.
|
||||
- `waitForDelta` memory leak — timer cleanup обязателен (см. INV-T10).
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"watch": ["./src"],
|
||||
"ext": "ts",
|
||||
"delay": 2000,
|
||||
"signal": "SIGTERM"
|
||||
"ext": "ts"
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.30-alpha-2",
|
||||
"version": "2026.5.23-4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.30-alpha-2",
|
||||
"version": "2026.5.23-4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.5.30-alpha-2",
|
||||
"version": "2026.5.23-4",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
@@ -72,11 +72,11 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
"@apollo/subgraph": "^2",
|
||||
"@aws-sdk/client-s3": "^3.1035.0",
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
"@coopenomics/parser2": "^1.2.0",
|
||||
"@coopenomics/provider-client": "2025.11.12-alpha-1",
|
||||
"@coopenomics/sdk": "workspace:*",
|
||||
"@graphql-codegen/typescript-graphql-request": "^6.2.0",
|
||||
@@ -100,6 +100,7 @@
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"@novu/api": "^1.4.0",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opensearch-project/opensearch": "^3.5.1",
|
||||
"@sentry/nestjs": "^10.32.1",
|
||||
"@sentry/node": "^10.32.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
@@ -193,12 +194,12 @@
|
||||
"@antfu/eslint-config": "^2.24.1",
|
||||
"@graphql-codegen/cli": "^5.0.3",
|
||||
"@graphql-codegen/typescript": "^4.1.1",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@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",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.26",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
// app.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
// Infrastructure modules
|
||||
@@ -9,12 +10,14 @@ import { GraphqlModule } from './infrastructure/graphql/graphql.module';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import config from '~/config/config';
|
||||
import { BlockchainModule } from './infrastructure/blockchain/blockchain.module';
|
||||
import { ForkRegistryModule } from './shared/sync/fork';
|
||||
import { GeneratorInfrastructureModule } from './infrastructure/generator/generator.module';
|
||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||
import { NovuModule } from './infrastructure/novu/novu.module';
|
||||
import { EventsInfrastructureModule } from './infrastructure/events/events.module';
|
||||
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
|
||||
@@ -57,7 +60,6 @@ import { QueueModule } from './application/queue/queue-app.module';
|
||||
import { DocumentModule } from './application/document/document.module';
|
||||
import { RedisAppModule } from './application/redis/redis-app.module';
|
||||
import { DecisionModule } from './application/free-decision/decision.module';
|
||||
import { DecisionAuthorizeModule } from './application/decision/decision.module';
|
||||
import { AgreementModule } from './application/agreement/agreement.module';
|
||||
import { ParticipantModule } from './application/participant/participant.module';
|
||||
import { AgendaModule } from './application/agenda/agenda.module';
|
||||
@@ -78,7 +80,6 @@ import { SettingsApplicationModule } from './application/settings/settings.modul
|
||||
import { RegistrationModule } from './application/registration/registration.module';
|
||||
import { OnboardingApplicationModule } from './application/onboarding/onboarding-application.module';
|
||||
import { SearchModule } from './application/search/search.module';
|
||||
import { SignedDocumentsModule } from './application/signed-documents/signed-documents.module';
|
||||
import { MutationLoggingInterceptor } from './application/common/interceptors/mutation-logging.interceptor';
|
||||
|
||||
@Module({
|
||||
@@ -86,6 +87,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // Чтобы .env был доступен глобально
|
||||
}),
|
||||
ScheduleModule.forRoot(), // @Cron / @Interval / @Timeout (Story 4.4 retention)
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000,
|
||||
@@ -96,10 +98,12 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
MongooseModule.forRoot(config.mongoose.url),
|
||||
DatabaseModule,
|
||||
GraphqlModule,
|
||||
ForkRegistryModule,
|
||||
BlockchainModule,
|
||||
GeneratorInfrastructureModule,
|
||||
RedisModule,
|
||||
NovuModule,
|
||||
SearchInfrastructureModule,
|
||||
EventsInfrastructureModule,
|
||||
FreeDecisionInfrastructureModule,
|
||||
DecisionTrackingInfrastructureModule,
|
||||
@@ -142,7 +146,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
AccountModule,
|
||||
AgreementModule,
|
||||
AgendaModule,
|
||||
DecisionAuthorizeModule,
|
||||
AppStoreModule,
|
||||
AuthModule,
|
||||
DesktopModule,
|
||||
@@ -171,7 +174,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
RegistrationModule,
|
||||
OnboardingApplicationModule,
|
||||
SearchModule,
|
||||
SignedDocumentsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DecisionResolver } from './resolvers/decision.resolver';
|
||||
import { DecisionService } from './services/decision.service';
|
||||
import { DecisionInteractor } from './use-cases/decision.interactor';
|
||||
|
||||
@Module({
|
||||
providers: [DecisionResolver, DecisionService, DecisionInteractor],
|
||||
})
|
||||
export class DecisionAuthorizeModule {}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Field, InputType, Int } from '@nestjs/graphql';
|
||||
import { IsNumber, IsString, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
|
||||
@InputType('AuthorizeDecisionInput')
|
||||
export class AuthorizeDecisionInputDTO {
|
||||
@Field(() => String, { description: 'Имя аккаунта кооператива' })
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { description: 'Имя аккаунта председателя совета' })
|
||||
@IsString()
|
||||
chairman!: string;
|
||||
|
||||
@Field(() => Int, { description: 'Идентификатор решения' })
|
||||
@IsNumber()
|
||||
decision_id!: number;
|
||||
|
||||
@Field(() => SignedDigitalDocumentInputDTO, { description: 'Подписанный председателем документ утверждения решения' })
|
||||
@ValidateNested()
|
||||
@Type(() => SignedDigitalDocumentInputDTO)
|
||||
document!: SignedDigitalDocumentInputDTO;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Resolver, Mutation, Args } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
|
||||
import { DecisionService } from '../services/decision.service';
|
||||
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
|
||||
|
||||
@Resolver()
|
||||
export class DecisionResolver {
|
||||
constructor(private readonly decisionService: DecisionService) {}
|
||||
|
||||
@Mutation(() => TransactionDTO, {
|
||||
name: 'authorizeDecision',
|
||||
description: 'Утвердить и исполнить решение совета',
|
||||
})
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman'])
|
||||
async authorizeDecision(
|
||||
@Args('data', { type: () => AuthorizeDecisionInputDTO }) data: AuthorizeDecisionInputDTO
|
||||
): Promise<TransactionDTO> {
|
||||
return this.decisionService.authorizeDecision(data);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DecisionInteractor } from '../use-cases/decision.interactor';
|
||||
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
|
||||
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DecisionService {
|
||||
constructor(private readonly decisionInteractor: DecisionInteractor) {}
|
||||
|
||||
public async authorizeDecision(data: AuthorizeDecisionInputDTO): Promise<TransactionDTO> {
|
||||
const result = await this.decisionInteractor.authorizeDecision(data);
|
||||
return result as TransactionDTO;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { SOVIET_BLOCKCHAIN_PORT, SovietBlockchainPort } from '~/domain/common/ports/soviet-blockchain.port';
|
||||
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
|
||||
import { SovietContract } from 'cooptypes';
|
||||
import type { TransactResult } from '@wharfkit/session';
|
||||
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DecisionInteractor {
|
||||
constructor(
|
||||
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort,
|
||||
private readonly domainToBlockchainUtils: DomainToBlockchainUtils
|
||||
) {}
|
||||
|
||||
async authorizeDecision(data: AuthorizeDecisionInputDTO): Promise<TransactResult> {
|
||||
const authorizeData: SovietContract.Actions.Decisions.Authorize.IAuthorize = {
|
||||
coopname: data.coopname,
|
||||
chairman: data.chairman,
|
||||
decision_id: data.decision_id,
|
||||
document: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.document),
|
||||
};
|
||||
|
||||
const execData: SovietContract.Actions.Decisions.Exec.IExec = {
|
||||
// executer — аудит-метка инициатора; авторизация транзакции идёт ключом кооператива.
|
||||
executer: data.chairman,
|
||||
coopname: data.coopname,
|
||||
decision_id: data.decision_id,
|
||||
};
|
||||
|
||||
return await this.sovietBlockchainPort.authorizeDecision(authorizeData, execData);
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,6 @@ import { DocumentPackageAggregator } from '~/domain/document/aggregators/documen
|
||||
import { DocumentAggregator } from '~/domain/document/aggregators/document.aggregator';
|
||||
import type { DocumentAggregateDomainInterface } from '~/domain/document/interfaces/document-domain-aggregate.interface';
|
||||
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
|
||||
import {
|
||||
SIGNED_DOCUMENT_REPOSITORY,
|
||||
type SignedDocumentRepository,
|
||||
} from '~/domain/document/repository/signed-document.repository';
|
||||
import { SignedDocumentStatus } from '~/domain/document/enums/signed-document-status.enum';
|
||||
import config from '~/config/config';
|
||||
|
||||
@Injectable()
|
||||
export class DocumentInteractor {
|
||||
@@ -22,8 +16,7 @@ export class DocumentInteractor {
|
||||
@Inject(forwardRef(() => DocumentDomainService)) private readonly documentDomainService: DocumentDomainService,
|
||||
@Inject(forwardRef(() => DocumentPackageAggregator))
|
||||
private readonly documentPackageAggregator: DocumentPackageAggregator,
|
||||
@Inject(forwardRef(() => DocumentAggregator)) private readonly documentAggregator: DocumentAggregator,
|
||||
@Inject(SIGNED_DOCUMENT_REPOSITORY) private readonly signedDocumentRepository: SignedDocumentRepository
|
||||
@Inject(forwardRef(() => DocumentAggregator)) private readonly documentAggregator: DocumentAggregator
|
||||
) {}
|
||||
|
||||
public async generateDocument(data: GenerateDocumentDomainInterfaceWithOptions): Promise<DocumentDomainEntity> {
|
||||
@@ -33,56 +26,52 @@ export class DocumentInteractor {
|
||||
public async getDocumentsAggregate(
|
||||
data: GetDocumentsInputDomainInterface
|
||||
): Promise<PaginationResultDomainInterface<DocumentPackageAggregateDomainInterface>> {
|
||||
const { type, page = 1, limit = 100, query = {}, after_block, before_block, actions } = data;
|
||||
const { type = 'newsubmitted', page = 1, limit = 100, query, after_block, before_block, actions } = data;
|
||||
|
||||
// Read-path из PG-реестра подписанных документов (C28-21): отдаём ГОТОВЫЙ агрегат,
|
||||
// собранный на этапе ingestion/backfill — без обращений к explorer/Mongo и без сборки на лету.
|
||||
const result = await this.signedDocumentRepository.findAggregates({
|
||||
coopname: config.coopname,
|
||||
status: this.mapTypeToStatus(type),
|
||||
actions: actions && actions.length > 0 ? (actions as unknown as string[]) : undefined,
|
||||
username: this.extractReceiverScope(query),
|
||||
hash: this.extractHashFilter(query),
|
||||
afterBlock: after_block,
|
||||
beforeBlock: before_block,
|
||||
// Добавляем фильтры по блокам в query
|
||||
const blockFilters: Record<string, unknown> = { ...query };
|
||||
if (after_block !== undefined || before_block !== undefined) {
|
||||
const blockFilter: Record<string, unknown> = {};
|
||||
|
||||
if (after_block !== undefined) {
|
||||
blockFilter.$gte = after_block;
|
||||
}
|
||||
if (before_block !== undefined) {
|
||||
blockFilter.$lte = before_block;
|
||||
}
|
||||
|
||||
blockFilters.block_num = blockFilter;
|
||||
}
|
||||
|
||||
// Добавляем фильтр по массиву действий (data.action)
|
||||
// DocumentAction enum значения автоматически преобразуются в строки
|
||||
if (actions && actions.length > 0) {
|
||||
blockFilters['data.action'] = { $in: actions };
|
||||
}
|
||||
|
||||
const signedDocuments = await this.documentDomainService.getImmutableSignedDocuments({
|
||||
type,
|
||||
page,
|
||||
limit,
|
||||
query: blockFilters,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items,
|
||||
totalCount: result.total,
|
||||
totalPages: Math.ceil(result.total / limit) || 0,
|
||||
const response: PaginationResultDomainInterface<DocumentPackageAggregateDomainInterface> = {
|
||||
items: [],
|
||||
totalCount: signedDocuments.total,
|
||||
totalPages: Math.ceil(signedDocuments.total / limit),
|
||||
currentPage: page,
|
||||
};
|
||||
}
|
||||
|
||||
// Прежний фильтр был по ИМЕНИ действия, не по статусу-партиции:
|
||||
// «Все входящие» (newsubmitted) — у каждого пакета есть submitted-действие, поэтому это ВСЕ
|
||||
// документы кооператива (решённые/аннулированные тоже входят) → статус НЕ ограничиваем;
|
||||
// «Только утверждённые» (newresolved) — лишь решённые → status=Resolved.
|
||||
// (Партиция по статусу скрыла бы решённые документы из вкладки «Все входящие» — регрессия.)
|
||||
private mapTypeToStatus(type?: 'newsubmitted' | 'newresolved'): SignedDocumentStatus | undefined {
|
||||
return type === 'newresolved' ? SignedDocumentStatus.Resolved : undefined;
|
||||
}
|
||||
for (const raw_action_document of signedDocuments.results) {
|
||||
const documentPackageAggregate = await this.documentPackageAggregator.buildDocumentPackageAggregate(
|
||||
raw_action_document
|
||||
);
|
||||
|
||||
// Скоуп выборки повторяет прежний explorer-фильтр `receiver: username` из DocumentService.
|
||||
// Документ soviet через require_recipient уведомляет И кооператив, И пайщика-субъекта, поэтому:
|
||||
// receiver === coopname → весь кооператив (фильтр только по coopname — как и было);
|
||||
// receiver === <пайщик> → его документы (колонка username = субъект заявления).
|
||||
private extractReceiverScope(query: Record<string, unknown>): string | undefined {
|
||||
const receiver = query['receiver'];
|
||||
if (typeof receiver === 'string' && receiver.length > 0 && receiver !== config.coopname) {
|
||||
return receiver;
|
||||
response.items.push(documentPackageAggregate);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Фильтр по конкретному документу: Union-страница шлёт filter.document.hash (upper-case).
|
||||
private extractHashFilter(query: Record<string, unknown>): string | undefined {
|
||||
const document = query['document'] as Record<string, unknown> | undefined;
|
||||
const hash = (document?.['hash'] ?? query['hash']) as unknown;
|
||||
return typeof hash === 'string' && hash.length > 0 ? hash : undefined;
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,4 +17,7 @@ export class Ledger2WalletDTO {
|
||||
|
||||
@Field(() => String, { description: 'Доступный баланс' })
|
||||
available!: string;
|
||||
|
||||
@Field(() => String, { description: 'Заблокированный баланс' })
|
||||
blocked!: string;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@ export class SearchResultDTO {
|
||||
@Field(() => String, { description: 'Имя пользователя' })
|
||||
username!: string;
|
||||
|
||||
@Field(() => String, { description: 'ФИО подписанта документа' })
|
||||
signer!: string;
|
||||
|
||||
@Field(() => String, { description: 'Кооператив' })
|
||||
coopname!: string;
|
||||
|
||||
|
||||
@@ -1,50 +1,24 @@
|
||||
import { Resolver, Query, Args } from '@nestjs/graphql';
|
||||
import { Inject, UseGuards } from '@nestjs/common';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { SearchResultDTO } from '../dto/search-result.dto';
|
||||
import { SearchDocumentsInputDTO } from '../dto/search-input.dto';
|
||||
import {
|
||||
SIGNED_DOCUMENT_REPOSITORY,
|
||||
type SignedDocumentRepository,
|
||||
} from '~/domain/document/repository/signed-document.repository';
|
||||
import { DocumentSearchService } from '~/infrastructure/search/opensearch.service';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import config from '~/config/config';
|
||||
|
||||
// Роли членов совета — им доступен поиск по всему документообороту кооператива.
|
||||
const COUNCIL_ROLES = ['chairman', 'member'];
|
||||
|
||||
@Resolver()
|
||||
export class SearchResolver {
|
||||
constructor(
|
||||
@Inject(SIGNED_DOCUMENT_REPOSITORY) private readonly signedDocuments: SignedDocumentRepository
|
||||
) {}
|
||||
constructor(private readonly documentSearch: DocumentSearchService) {}
|
||||
|
||||
// Поиск по документам кооператива. Член совета (chairman/member) ищет по всему кооперативу;
|
||||
// обычный пайщик — ТОЛЬКО по своим документам (скоуп по username), чтобы не видеть чужие.
|
||||
@Query(() => [SearchResultDTO], { description: 'Полнотекстовый поиск по документам кооператива' })
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async searchDocuments(
|
||||
@Args('data') input: SearchDocumentsInputDTO,
|
||||
@CurrentUser() user: MonoAccountDomainInterface
|
||||
): Promise<SearchResultDTO[]> {
|
||||
const isCouncil = COUNCIL_ROLES.includes(user?.role);
|
||||
const hits = await this.signedDocuments.search({
|
||||
coopname: config.coopname,
|
||||
query: input.query,
|
||||
limit: input.limit || 20,
|
||||
username: isCouncil ? undefined : user.username,
|
||||
});
|
||||
if (!this.documentSearch.isAvailable()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return hits.map((hit) => ({
|
||||
hash: hit.hash,
|
||||
full_title: hit.full_title,
|
||||
username: hit.username,
|
||||
signer: hit.signer,
|
||||
coopname: hit.coopname,
|
||||
registry_id: hit.registry_id,
|
||||
created_at: hit.created_at ?? undefined,
|
||||
highlights: hit.highlights,
|
||||
}));
|
||||
return this.documentSearch.search(input.query, config.coopname, input.limit || 20);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SearchResolver } from './resolvers/search.resolver';
|
||||
import { SearchEventService } from './services/search-event.service';
|
||||
import { DocumentModule } from '~/application/document/document.module';
|
||||
|
||||
/**
|
||||
* Поиск по документам кооператива (C28-21).
|
||||
* searchDocuments читает PG-реестр подписанных документов через SIGNED_DOCUMENT_REPOSITORY
|
||||
* (глобальный TypeOrmModule). OpenSearch и его индексатор удалены.
|
||||
*/
|
||||
@Module({
|
||||
providers: [SearchResolver],
|
||||
imports: [DocumentModule],
|
||||
providers: [SearchResolver, SearchEventService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { SovietContract } from 'cooptypes';
|
||||
import { DocumentSearchService } from '~/infrastructure/search/opensearch.service';
|
||||
import { GeneratorInfrastructureService } from '~/infrastructure/generator/generator.service';
|
||||
import { DocumentInteractor } from '~/application/document/interactors/document.interactor';
|
||||
import type { IAction } from '~/types/common';
|
||||
|
||||
/**
|
||||
* Сервис индексации документов по событию blockchain.
|
||||
* Слушает action::soviet::newsubmitted — подписанные и принятые документы.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchEventService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SearchEventService.name);
|
||||
|
||||
constructor(
|
||||
private readonly documentSearch: DocumentSearchService,
|
||||
private readonly generatorService: GeneratorInfrastructureService,
|
||||
private readonly documentInteractor: DocumentInteractor,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
if (this.documentSearch.isAvailable()) {
|
||||
// Запускаем асинхронную ре-индексацию при старте
|
||||
this.reindexAllDocuments().catch((err) => {
|
||||
this.logger.error(`Ошибка при фоновой ре-индексации: ${err.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Метод полной ре-индексации только документов типа newsubmitted из блокчейна.
|
||||
* Собирает агрегаты для получения полного содержимого документа.
|
||||
*/
|
||||
async reindexAllDocuments(): Promise<void> {
|
||||
this.logger.log('Начало полной ре-индексации документов (newsubmitted)...');
|
||||
let totalIndexed = 0;
|
||||
let currentPage = 1;
|
||||
const limit = 100;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const result = await this.documentInteractor.getDocumentsAggregate({
|
||||
type: 'newsubmitted',
|
||||
page: currentPage,
|
||||
limit,
|
||||
query: {}, // Обязательное поле согласно GetDocumentsInputDomainInterface
|
||||
});
|
||||
|
||||
if (!result.items || result.items.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const packageAggregate of result.items) {
|
||||
// В пакете может быть заявление, решение и акты. Индексируем всё, что есть.
|
||||
const aggregatesToindex = [
|
||||
packageAggregate.statement?.documentAggregate,
|
||||
packageAggregate.decision?.documentAggregate,
|
||||
...packageAggregate.acts.map(act => act.documentAggregate),
|
||||
...packageAggregate.links
|
||||
].filter(Boolean);
|
||||
|
||||
for (const aggregate of aggregatesToindex) {
|
||||
const doc = aggregate?.rawDocument;
|
||||
const signedDoc = aggregate?.document;
|
||||
|
||||
if (!doc || !signedDoc) continue;
|
||||
|
||||
try {
|
||||
await this.documentSearch.indexDocument({
|
||||
hash: doc.hash,
|
||||
full_title: doc.full_title || '',
|
||||
html: doc.html || '',
|
||||
username: doc.meta?.username || signedDoc.meta?.username || '',
|
||||
coopname: doc.meta?.coopname || signedDoc.meta?.coopname || '',
|
||||
registry_id: doc.meta?.registry_id || signedDoc.meta?.registry_id || 0,
|
||||
created_at: doc.meta?.created_at || signedDoc.meta?.created_at || '',
|
||||
});
|
||||
totalIndexed++;
|
||||
} catch (docError: any) {
|
||||
this.logger.warn(`Ошибка индексации документа ${doc.hash}: ${docError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPage >= result.totalPages) {
|
||||
break;
|
||||
}
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
this.logger.log(`Ре-индексация завершена. Проиндексировано документов: ${totalIndexed}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка при ре-индексации документов: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent(`action::${SovietContract.contractName.production}::${SovietContract.Actions.Registry.NewSubmitted.actionName}`)
|
||||
async onDocumentSubmitted(action: IAction): Promise<void> {
|
||||
if (!this.documentSearch.isAvailable()) return;
|
||||
|
||||
try {
|
||||
const docHash = action.data?.document?.hash;
|
||||
if (!docHash) {
|
||||
this.logger.debug('Событие newsubmitted без хеша документа, пропускаем');
|
||||
return;
|
||||
}
|
||||
|
||||
const document = await this.generatorService.getDocument({ hash: docHash });
|
||||
if (!document) {
|
||||
this.logger.debug(`Документ ${docHash.substring(0, 8)} не найден в MongoDB`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.documentSearch.indexDocument({
|
||||
hash: document.hash,
|
||||
full_title: document.full_title || '',
|
||||
html: document.html || '',
|
||||
username: document.meta?.username || action.data?.username || '',
|
||||
coopname: document.meta?.coopname || action.data?.coopname || '',
|
||||
registry_id: document.meta?.registry_id || 0,
|
||||
created_at: document.meta?.created_at || '',
|
||||
});
|
||||
|
||||
this.logger.log(`Документ проиндексирован: ${document.full_title?.substring(0, 50)}`);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Ошибка индексации документа по событию newsubmitted: ${error?.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
|
||||
import {
|
||||
SIGNED_DOCUMENT_REPOSITORY,
|
||||
type SignedDocumentRepository,
|
||||
} from '~/domain/document/repository/signed-document.repository';
|
||||
import { SignedDocumentStatus } from '~/domain/document/enums/signed-document-status.enum';
|
||||
import { SignedDocumentIngestionService } from './signed-document-ingestion.service';
|
||||
import config from '~/config/config';
|
||||
import type { IAction } from '~/types/common';
|
||||
|
||||
export interface BackfillStats {
|
||||
scanned: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface BackfillPass {
|
||||
type: string;
|
||||
status: SignedDocumentStatus;
|
||||
}
|
||||
|
||||
// Порядок важен: сперва базовые записи (submitted/resolved), затем смена статусов поверх.
|
||||
const PASSES: BackfillPass[] = [
|
||||
{ type: 'newsubmitted', status: SignedDocumentStatus.Submitted },
|
||||
{ type: 'newresolved', status: SignedDocumentStatus.Resolved },
|
||||
// newdeclined пока не приходит как registry-action (см. C28-21); пройдёт пустым — это безопасно.
|
||||
{ type: 'newdeclined', status: SignedDocumentStatus.Declined },
|
||||
];
|
||||
|
||||
const PAGE_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Разовый backfill реестра подписанных документов (C28-21).
|
||||
*
|
||||
* Сканирует все действия soviet (newsubmitted/newresolved/newdeclined) через explorer-источник,
|
||||
* для каждого достаёт документ из factory-Mongo и складывает в Postgres-проекцию.
|
||||
* Идемпотентен по (coopname, doc_hash) — повторный прогон безопасен (package НЕ уникален:
|
||||
* в одном процессе несколько разных документов, поэтому ключ записи — doc_hash).
|
||||
*
|
||||
* Запуск автоматически ОДИН РАЗ при первом старте с этой фичей: если реестр по кооперативу пуст —
|
||||
* затягиваем всю историю. Принудительный повторный прогон — флагом `SIGNED_DOCS_BACKFILL_ON_BOOT=true`.
|
||||
* Выполняется детачем в onApplicationBootstrap (после инициализации всех модулей — генератор уже
|
||||
* подключён к Mongo), чтобы не блокировать старт. Метод `run()` пригоден и для отдельного CLI.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SignedDocumentBackfillService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(SignedDocumentBackfillService.name);
|
||||
|
||||
constructor(
|
||||
private readonly documentDomainService: DocumentDomainService,
|
||||
private readonly ingestion: SignedDocumentIngestionService,
|
||||
@Inject(SIGNED_DOCUMENT_REPOSITORY) private readonly repository: SignedDocumentRepository
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
// Детачем: не блокируем старт приложения долгим сканом истории.
|
||||
this.maybeRunOnBoot().catch((error) => {
|
||||
this.logger.error(`Ошибка фонового backfill реестра: ${error?.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async maybeRunOnBoot(): Promise<void> {
|
||||
const forced = process.env.SIGNED_DOCS_BACKFILL_ON_BOOT === 'true';
|
||||
if (!forced) {
|
||||
const existing = await this.repository.count(config.coopname);
|
||||
if (existing > 0) {
|
||||
this.logger.log(`Реестр подписанных документов не пуст (${existing}) — авто-backfill пропущен.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.run();
|
||||
}
|
||||
|
||||
async run(): Promise<BackfillStats> {
|
||||
const stats: BackfillStats = { scanned: 0, created: 0, updated: 0, skipped: 0 };
|
||||
this.logger.log('Старт backfill реестра подписанных документов...');
|
||||
|
||||
for (const pass of PASSES) {
|
||||
await this.runPass(pass, stats);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Backfill завершён. scanned=${stats.scanned} created=${stats.created} updated=${stats.updated} skipped=${stats.skipped}`
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
|
||||
private async runPass(pass: BackfillPass, stats: BackfillStats): Promise<void> {
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
// Скоуп ровно как у прежнего chairman-пути getDocuments: {name: type, receiver: coopname}.
|
||||
// Без receiver explorer вернул бы все трейсы каждого действия (receiver=soviet/пайщик) и,
|
||||
// возможно, другие кооперативы — лишняя обработка и чужие данные. receiver=coopname даёт
|
||||
// один трейс на документ и только наш кооператив (coopname — всегда получатель soviet-документа).
|
||||
const response = await this.documentDomainService.getImmutableSignedDocuments({
|
||||
type: pass.type,
|
||||
query: { receiver: config.coopname },
|
||||
page,
|
||||
limit: PAGE_LIMIT,
|
||||
});
|
||||
|
||||
const results = (response?.results ?? []) as unknown as IAction[];
|
||||
if (results.length === 0) break;
|
||||
|
||||
for (const action of results) {
|
||||
stats.scanned++;
|
||||
try {
|
||||
const result = await this.ingestion.ingestAction(action, pass.status);
|
||||
stats[result === 'created' ? 'created' : result === 'updated' ? 'updated' : 'skipped']++;
|
||||
} catch (error: any) {
|
||||
// Одна битая запись (напр. не-парсибельная дата у legacy-документа) не должна ронять
|
||||
// весь прогон backfill — логируем и идём дальше.
|
||||
stats.skipped++;
|
||||
this.logger.warn(`Пропущен документ при backfill (${pass.type}): ${error?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length < PAGE_LIMIT) break;
|
||||
page++;
|
||||
}
|
||||
}
|
||||
}
|
||||
-291
@@ -1,291 +0,0 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Cooperative, SovietContract } from 'cooptypes';
|
||||
import { DocumentPackageAggregator } from '~/domain/document/aggregators/document-package.aggregator';
|
||||
import {
|
||||
SIGNED_DOCUMENT_REPOSITORY,
|
||||
type SignedDocumentPackageRow,
|
||||
type SignedDocumentRepository,
|
||||
} from '~/domain/document/repository/signed-document.repository';
|
||||
import { SignedDocumentStatus } from '~/domain/document/enums/signed-document-status.enum';
|
||||
import type { DocumentPackageAggregateDomainInterface } from '~/domain/document/interfaces/document-package-aggregate-domain.interface';
|
||||
import config from '~/config/config';
|
||||
import type { IAction } from '~/types/common';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
const SOVIET = SovietContract.contractName.production;
|
||||
const Registry = SovietContract.Actions.Registry;
|
||||
|
||||
export type IngestResult = 'created' | 'updated' | 'skipped';
|
||||
|
||||
/**
|
||||
* Наполнение реестра подписанных документов (Postgres-проекция, C28-21).
|
||||
*
|
||||
* Источник — внутренняя событийная шина (EventEmitter2): парсер ретранслирует blockchain-события
|
||||
* контракта soviet как `action::soviet::*`. На каждое релевантное событие собирается ГОТОВЫЙ агрегат
|
||||
* пакета (DocumentPackageAggregator) и кладётся в PG целиком — чтобы getDocuments/поиск работали
|
||||
* из Postgres без сборки на лету. Парсер не трогаем (parser1→parser2 прозрачно для подписчика).
|
||||
*
|
||||
* - newsubmitted/newresolved/newdeclined — несут заявление: пересобираем агрегат + ставим статус;
|
||||
* - newdecision/newact/newlink — досборка: пересобираем агрегат по сохранённому действию-носителю,
|
||||
* статус не меняем.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SignedDocumentIngestionService {
|
||||
private readonly logger = new Logger(SignedDocumentIngestionService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(SIGNED_DOCUMENT_REPOSITORY) private readonly repository: SignedDocumentRepository,
|
||||
private readonly aggregator: DocumentPackageAggregator
|
||||
) {}
|
||||
|
||||
@OnEvent(`action::${SOVIET}::${Registry.NewSubmitted.actionName}`)
|
||||
async onNewSubmitted(action: IAction): Promise<void> {
|
||||
await this.safeStatusEvent(action, SignedDocumentStatus.Submitted, Registry.NewSubmitted.actionName);
|
||||
}
|
||||
|
||||
@OnEvent(`action::${SOVIET}::${Registry.NewResolved.actionName}`)
|
||||
async onNewResolved(action: IAction): Promise<void> {
|
||||
await this.safeStatusEvent(action, SignedDocumentStatus.Resolved, Registry.NewResolved.actionName);
|
||||
}
|
||||
|
||||
@OnEvent(`action::${SOVIET}::${Registry.NewDeclined.actionName}`)
|
||||
async onNewDeclined(action: IAction): Promise<void> {
|
||||
await this.safeStatusEvent(action, SignedDocumentStatus.Declined, Registry.NewDeclined.actionName);
|
||||
}
|
||||
|
||||
@OnEvent(`action::${SOVIET}::${Registry.NewDecision.actionName}`)
|
||||
async onNewDecision(action: IAction): Promise<void> {
|
||||
await this.safeReassemble(action, Registry.NewDecision.actionName);
|
||||
}
|
||||
|
||||
@OnEvent(`action::${SOVIET}::${Registry.NewAct.actionName}`)
|
||||
async onNewAct(action: IAction): Promise<void> {
|
||||
await this.safeReassemble(action, Registry.NewAct.actionName);
|
||||
}
|
||||
|
||||
@OnEvent(`action::${SOVIET}::${Registry.NewLink.actionName}`)
|
||||
async onNewLink(action: IAction): Promise<void> {
|
||||
await this.safeReassemble(action, Registry.NewLink.actionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Идемпотентно записывает действие-носитель заявления в реестр.
|
||||
* Используется и листенерами шины, и backfill'ом.
|
||||
*/
|
||||
async ingestAction(action: IAction, status: SignedDocumentStatus): Promise<IngestResult> {
|
||||
const data = (action?.data ?? {}) as Record<string, any>;
|
||||
// Ключ записи — doc_hash (идентичность документа). hash включает подписи, поэтому у одного
|
||||
// документа бывает несколько hash (версии по мере накопления подписей — напр. акт приёма-передачи:
|
||||
// первая/вторая подпись дают разные hash при одном doc_hash). Держим КРАЙНЮЮ версию по номеру блока,
|
||||
// иначе документ задвоился бы в списке. package НЕ ключ: в одном процессе бывает несколько разных
|
||||
// документов с разными подписантами (обмен в marketplace).
|
||||
const docHash: string | undefined = data.document?.doc_hash;
|
||||
if (!docHash) return 'skipped';
|
||||
const hash: string = data.document?.hash || '';
|
||||
const packageHash: string = data.package || '';
|
||||
const coopname: string = data.coopname || config.coopname;
|
||||
const incomingBlock = this.toBlockNum(action?.block_num);
|
||||
|
||||
const existing = await this.repository.getState(coopname, docHash);
|
||||
if (existing) {
|
||||
// Не понижаем статус: submitted не перетирает resolved/declined.
|
||||
if (status === SignedDocumentStatus.Submitted && existing.status !== SignedDocumentStatus.Submitted) {
|
||||
return 'skipped';
|
||||
}
|
||||
// Не откатываем на более старую версию подписи (меньший номер блока).
|
||||
if (incomingBlock !== null && existing.blockNum !== null && incomingBlock < existing.blockNum) {
|
||||
return 'skipped';
|
||||
}
|
||||
// Идемпотентность: тот же статус и не новее по блоку — обновлять нечего (повторный backfill безопасен).
|
||||
if (
|
||||
existing.status === status &&
|
||||
(incomingBlock === null || existing.blockNum === null || incomingBlock <= existing.blockNum)
|
||||
) {
|
||||
return 'skipped';
|
||||
}
|
||||
}
|
||||
|
||||
await this.assembleAndUpsert(action, status, coopname, docHash, hash, packageHash);
|
||||
return existing ? 'updated' : 'created';
|
||||
}
|
||||
|
||||
private toBlockNum(value: unknown): number | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
private async safeStatusEvent(action: IAction, status: SignedDocumentStatus, label: string): Promise<void> {
|
||||
try {
|
||||
const result = await this.ingestAction(action, status);
|
||||
if (result !== 'skipped') {
|
||||
this.logger.log(`[${label}] запись реестра ${result}: package=${this.shortPackage(action)}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Ошибка ingestion события ${label}: ${error?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async safeReassemble(action: IAction, label: string): Promise<void> {
|
||||
try {
|
||||
const data = (action?.data ?? {}) as Record<string, any>;
|
||||
const packageHash: string | undefined = data.package;
|
||||
if (!packageHash) return;
|
||||
|
||||
const coopname: string = data.coopname || config.coopname;
|
||||
// decision/act/link относятся к процессу (package), а не к конкретному заявлению.
|
||||
// Пересобираем агрегаты ВСЕХ заявлений пакета (их может быть несколько с разными
|
||||
// подписантами), сохраняя статус каждого.
|
||||
const rows = await this.repository.findByPackage(coopname, packageHash);
|
||||
if (rows.length === 0) return; // заявлений по пакету ещё нет — нечего дособирать
|
||||
|
||||
let rebuilt = 0;
|
||||
for (const row of rows) {
|
||||
if (await this.reassembleRow(row, coopname)) rebuilt++;
|
||||
}
|
||||
if (rebuilt > 0) {
|
||||
this.logger.log(`[${label}] пересобрано агрегатов: ${rebuilt}, package=${this.shortPackage(action)}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Ошибка пересборки агрегата по событию ${label}: ${error?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересобирает агрегат ОДНОЙ записи пакета по её сохранённому действию-носителю и обновляет
|
||||
* ТОЛЬКО агрегат-производные колонки. Статус/версию/source НЕ трогаем — ими владеют статус-события
|
||||
* (иначе пересборка, конкурентная с newresolved, откатила бы resolved→submitted). Если агрегат не
|
||||
* собрался (null) — запись не портим, оставляем прежний агрегат.
|
||||
*/
|
||||
private async reassembleRow(row: SignedDocumentPackageRow, coopname: string): Promise<boolean> {
|
||||
if (!row.sourceActionData) return false;
|
||||
const aggregate = await this.buildAggregateSafe(row.sourceActionData as unknown as IAction);
|
||||
if (!aggregate) return false;
|
||||
const rawDoc = aggregate.statement?.documentAggregate?.rawDocument;
|
||||
await this.repository.updateAggregate(coopname, row.doc_hash, {
|
||||
document_aggregate: aggregate,
|
||||
full_title: rawDoc?.full_title || '',
|
||||
content_text: this.stripHtml(rawDoc?.html || ''),
|
||||
signers_text: this.collectSignersText(aggregate),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async assembleAndUpsert(
|
||||
sourceAction: IAction,
|
||||
status: SignedDocumentStatus,
|
||||
coopname: string,
|
||||
docHash: string,
|
||||
hash: string,
|
||||
packageHash: string
|
||||
): Promise<void> {
|
||||
const aggregate = await this.buildAggregateSafe(sourceAction);
|
||||
const data = (sourceAction?.data ?? {}) as Record<string, any>;
|
||||
const statement = aggregate?.statement;
|
||||
const rawDoc = statement?.documentAggregate?.rawDocument;
|
||||
const meta = (rawDoc?.meta ?? {}) as Record<string, any>;
|
||||
|
||||
await this.repository.upsert({
|
||||
coopname,
|
||||
packageHash,
|
||||
doc_hash: docHash,
|
||||
hash,
|
||||
username: meta.username || data.username || '',
|
||||
status,
|
||||
action: data.action || '',
|
||||
registry_id: typeof meta.registry_id === 'number' ? meta.registry_id : 0,
|
||||
full_title: rawDoc?.full_title || '',
|
||||
content_text: this.stripHtml(rawDoc?.html || ''),
|
||||
signers_text: this.collectSignersText(aggregate),
|
||||
block_num: this.resolveBlockNum(sourceAction?.block_num, meta.block_num),
|
||||
document_created_at: this.parseDocumentDate(meta.created_at, meta.timezone),
|
||||
document_aggregate: aggregate,
|
||||
source_action_data: sourceAction as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
private async buildAggregateSafe(action: IAction): Promise<DocumentPackageAggregateDomainInterface | null> {
|
||||
try {
|
||||
return await this.aggregator.buildDocumentPackageAggregate(action as unknown as Cooperative.Blockchain.IAction);
|
||||
} catch (error: any) {
|
||||
this.logger.debug(`Не удалось собрать агрегат пакета: ${error?.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает ФИО/наименования всех подписантов пакета из signer_certificate всех частей агрегата
|
||||
* (заявление/решение/акты/связанные). Это и есть основной индекс поиска по подписанту —
|
||||
* соподписанты (председатель/совет) подписывают решение и акты, а не тело заявления,
|
||||
* поэтому content_text их не покрывает. Username добавляется тоже (редкий поиск по нему).
|
||||
*/
|
||||
private collectSignersText(aggregate: DocumentPackageAggregateDomainInterface | null): string {
|
||||
if (!aggregate) return '';
|
||||
|
||||
const docAggregates = [
|
||||
aggregate.statement?.documentAggregate,
|
||||
aggregate.decision?.documentAggregate,
|
||||
...aggregate.acts.map((act) => act?.documentAggregate),
|
||||
...aggregate.links,
|
||||
];
|
||||
|
||||
const parts = new Set<string>();
|
||||
for (const da of docAggregates) {
|
||||
const signatures = da?.document?.signatures ?? [];
|
||||
for (const sig of signatures) {
|
||||
if (sig?.signer) parts.add(String(sig.signer));
|
||||
const cert = sig?.signer_certificate as Record<string, any> | null | undefined;
|
||||
if (!cert) continue;
|
||||
for (const key of ['last_name', 'first_name', 'middle_name', 'short_name']) {
|
||||
if (cert[key]) parts.add(String(cert[key]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(parts).join(' ');
|
||||
}
|
||||
|
||||
private shortPackage(action: IAction): string {
|
||||
const pkg = (action?.data as Record<string, any>)?.package;
|
||||
return typeof pkg === 'string' ? pkg.substring(0, 8) : '?';
|
||||
}
|
||||
|
||||
/**
|
||||
* Номер блока для колонки block_num — ось упорядочивания ВЕРСИЙ подписи одного doc_hash.
|
||||
* Это блок ON-CHAIN ДЕЙСТВИЯ (action.block_num), а НЕ блок генерации документа (meta.block_num):
|
||||
* guard'ы в ingestAction сравнивают пришедший action.block_num с хранимым block_num, поэтому обе
|
||||
* стороны должны мерить одну величину. Иначе (meta.block_num — блок генерации, всегда ≤ блока
|
||||
* подачи в цепь) защита от отката на старую версию и идемпотентность по блоку не срабатывают.
|
||||
* meta.block_num — только запасной вариант, если у действия нет block_num.
|
||||
*/
|
||||
private resolveBlockNum(actionBlockNum: unknown, metaBlockNum: unknown): string | null {
|
||||
if (actionBlockNum !== undefined && actionBlockNum !== null) return String(actionBlockNum);
|
||||
if (metaBlockNum !== undefined && metaBlockNum !== null) return String(metaBlockNum);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит meta.created_at в Date для колонки timestamptz. Документы хранят дату в
|
||||
* ЛОКАЛИЗОВАННОМ формате `DD.MM.YYYY HH:mm` + отдельное поле meta.timezone — именно так её
|
||||
* пишет фабрика @coopenomics/factory (Generator.updateMetadata через moment.tz). Нативный
|
||||
* `new Date(meta.created_at)` этот формат НЕ понимает и даёт Invalid Date → pg-драйвер пишет
|
||||
* в timestamptz "0NaN-NaN-NaN…" и вся вставка падает. Парсим тем же moment.tz, что и фабрика;
|
||||
* timezone берём из меты, иначе — из конфига кооператива. Невалидное/пустое значение → null
|
||||
* (одна битая дата не должна ронять запись и весь backfill).
|
||||
*/
|
||||
private parseDocumentDate(createdAt: unknown, timezone: unknown): Date | null {
|
||||
if (!createdAt || typeof createdAt !== 'string') return null;
|
||||
const tz = typeof timezone === 'string' && timezone ? timezone : config.timezone;
|
||||
const parsed = moment.tz(createdAt, 'DD.MM.YYYY HH:mm', tz);
|
||||
return parsed.isValid() ? parsed.toDate() : null;
|
||||
}
|
||||
|
||||
private stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DocumentDomainModule } from '~/domain/document/document.module';
|
||||
import { SignedDocumentIngestionService } from './services/signed-document-ingestion.service';
|
||||
import { SignedDocumentBackfillService } from './services/signed-document-backfill.service';
|
||||
|
||||
/**
|
||||
* Реестр подписанных документов в Postgres (C28-21).
|
||||
*
|
||||
* Ingestion-листенер ловит blockchain-события soviet с внутренней шины и наполняет PG-проекцию;
|
||||
* backfill — разовый перенос истории. Репозиторий (SIGNED_DOCUMENT_REPOSITORY) и генератор
|
||||
* предоставляются глобальными модулями (TypeOrmModule / GeneratorInfrastructureModule).
|
||||
*
|
||||
* Это единый источник для getDocuments (read-path) и searchDocuments — OpenSearch удалён.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DocumentDomainModule],
|
||||
providers: [SignedDocumentIngestionService, SignedDocumentBackfillService],
|
||||
exports: [SignedDocumentIngestionService, SignedDocumentBackfillService],
|
||||
})
|
||||
export class SignedDocumentsModule {}
|
||||
@@ -234,8 +234,7 @@ export class SystemInteractor {
|
||||
union_link: config.union.link,
|
||||
board_members,
|
||||
features: {
|
||||
// Поиск по документам теперь на PG-реестре подписанных документов (C28-21) — доступен всегда.
|
||||
search: true,
|
||||
search: process.env.OPENSEARCH_ENABLED === 'true',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ export class ProgramWalletDTO {
|
||||
@Field(() => String, { description: 'Доступный баланс (формат: "100.0000 RUB")' })
|
||||
available!: string;
|
||||
|
||||
@Field(() => String, { description: 'Заблокированный баланс (формат: "100.0000 RUB")' })
|
||||
blocked!: string;
|
||||
|
||||
@Field(() => String, { description: 'Паевой взнос (формат: "100.0000 RUB")' })
|
||||
membership_contribution!: string;
|
||||
|
||||
@@ -46,6 +49,7 @@ export class ProgramWalletDTO {
|
||||
dto.agreement_id = entity.agreement_id;
|
||||
dto.username = entity.username;
|
||||
dto.available = entity.available;
|
||||
dto.blocked = entity.blocked;
|
||||
dto.membership_contribution = entity.membership_contribution;
|
||||
dto.blockNum = entity.blockNum;
|
||||
return dto;
|
||||
|
||||
@@ -49,12 +49,4 @@ export class ProgramWalletSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации программных кошельков полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для программных кошельков
|
||||
* Подписывается на все форки независимо от контракта
|
||||
*/
|
||||
async handleProgramWalletFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,41 @@ const envVarsSchema = z.object({
|
||||
.string()
|
||||
.default('1000')
|
||||
.transform((val) => parseInt(val, 10)),
|
||||
|
||||
/**
|
||||
* Задержка (мс) перед emit'ом action-события во внутреннюю шину. Даёт
|
||||
* дельтам того же блока сохраниться в БД раньше, чем обработчики action
|
||||
* полезут читать состояние (DEC-007, ранее хардкод-константа 3000).
|
||||
*/
|
||||
BLOCKCHAIN_ACTION_EMIT_DELAY_MS: z
|
||||
.string()
|
||||
.default('3000')
|
||||
.transform((val) => parseInt(val, 10)),
|
||||
/**
|
||||
* Story 4.4: глобальный выключатель ежечасного retention-крона архива
|
||||
* invalidated_entities/invalidated_entity_versions. На малом объёме (нынешний
|
||||
* кооператив) данные могут копиться годами — отключить кроном.
|
||||
*/
|
||||
BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((v) => v === 'true'),
|
||||
/**
|
||||
* Story 4.4: cron-расписание retention. Default — ежечасно. RETENTION_HORIZON_BLOCKS
|
||||
* (=1000) хардкод в BlockchainArchiveRetentionService — не вынесен в env намеренно
|
||||
* (свойство сети, не оператора).
|
||||
*/
|
||||
BLOCKCHAIN_ARCHIVE_RETENTION_CRON: z.string().default('0 * * * *'),
|
||||
/**
|
||||
* Story 6.5: при `true` mapper-fail (mapDeltaToBlockchainData → null) перестаёт
|
||||
* быть silent loss и поднимается `UnsupportedContractVersionError` из
|
||||
* `AbstractEntitySyncService.processDelta`. Парсер не ACK'ает delta — DLQ
|
||||
* сработает. Default `false` для не-ломать-прод-немедленно; включается после
|
||||
* подтверждения, что schema drift отсутствует (например на стенде).
|
||||
*/
|
||||
BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((v) => v === 'true'),
|
||||
// Параметры NOVU
|
||||
NOVU_APP_ID: z.string().min(1, { message: 'Не должно быть пустым' }),
|
||||
NOVU_BACKEND_URL: z.string().min(1, { message: 'Не должно быть пустым' }).default('https://novu.coopenomics.world/api'),
|
||||
@@ -193,18 +227,6 @@ const envVarsSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
|
||||
|
||||
// apps-catalog proxy (Story 9.5.b). Витрина remote-пакетов — это HTTP-прокси
|
||||
// через controller → ca-admin /v1/public/packages. Браузер напрямую в каталог
|
||||
// не ходит, admin-secret в SPA не утекает (см. architecture-choice C).
|
||||
APPS_CATALOG_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Base URL ca-admin (apps-catalog). Пусто = резолвер вернёт пустой список (degraded mode).'),
|
||||
APPS_CATALOG_API_KEY: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Admin API ключ для apps-catalog. Пусто = degraded mode.'),
|
||||
});
|
||||
|
||||
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
|
||||
@@ -243,6 +265,10 @@ export default {
|
||||
root_precision: envVars.data.ROOT_PRECISION,
|
||||
root_govern_precision: envVars.data.ROOT_GOVERN_PRECISION,
|
||||
post_transact_chain_read_delay_ms: envVars.data.POST_TRANSACT_CHAIN_READ_DELAY_MS,
|
||||
action_emit_delay_ms: envVars.data.BLOCKCHAIN_ACTION_EMIT_DELAY_MS,
|
||||
archive_retention_enabled: envVars.data.BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED,
|
||||
archive_retention_cron: envVars.data.BLOCKCHAIN_ARCHIVE_RETENTION_CRON,
|
||||
unsupported_version_strict: envVars.data.BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT,
|
||||
},
|
||||
mongoose: {
|
||||
url: envVars.data.MONGODB_URL + (envVars.data.NODE_ENV === 'test' ? '-test' : ''),
|
||||
@@ -336,8 +362,4 @@ export default {
|
||||
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,
|
||||
},
|
||||
apps_catalog: {
|
||||
url: envVars.data.APPS_CATALOG_URL,
|
||||
api_key: envVars.data.APPS_CATALOG_API_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,29 +26,22 @@ const logger = winston.createLogger({
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
// При генерации схемы (build-time codegen) файловые транспорты не подключаем:
|
||||
// процесс запускается из-под пользователя рядом с работающим контейнером, чей logs/
|
||||
// принадлежит root, — запись туда падает с EACCES. На рантайм не влияет (флаг ставится только в codegen).
|
||||
...(process.env.CONTROLLER_SCHEMA_GEN
|
||||
? []
|
||||
: [
|
||||
new DailyRotateFile({
|
||||
filename: 'logs/application-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
level: 'info',
|
||||
}),
|
||||
new DailyRotateFile({
|
||||
filename: 'logs/error-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
level: 'error',
|
||||
}),
|
||||
]),
|
||||
new DailyRotateFile({
|
||||
filename: 'logs/application-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
level: 'info',
|
||||
}),
|
||||
new DailyRotateFile({
|
||||
filename: 'logs/error-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
level: 'error',
|
||||
}),
|
||||
new winston.transports.Console({
|
||||
stderrLevels: ['error'],
|
||||
level: config.env === 'development' ? 'debug' : 'info',
|
||||
|
||||
@@ -25,14 +25,6 @@ export interface SovietBlockchainPort {
|
||||
confirmAgreement(data: SovietContract.Actions.Agreements.ConfirmAgreement.IConfirmAgreement): Promise<TransactResult>;
|
||||
|
||||
declineAgreement(data: SovietContract.Actions.Agreements.DeclineAgreement.IDeclineAgreement): Promise<TransactResult>;
|
||||
|
||||
// Утверждение + исполнение решения совета одной транзакцией ключом кооператива
|
||||
// (soviet::authorize + soviet::exec). Согласие председателя — в подписанном им
|
||||
// документе внутри authorizeData.document.
|
||||
authorizeDecision(
|
||||
authorizeData: SovietContract.Actions.Decisions.Authorize.IAuthorize,
|
||||
execData: SovietContract.Actions.Decisions.Exec.IExec
|
||||
): Promise<TransactResult>;
|
||||
}
|
||||
|
||||
export const SOVIET_BLOCKCHAIN_PORT = Symbol('SovietBlockchainPort');
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Статус подписанного документа в реестре подписанных документов (Postgres-проекция).
|
||||
*
|
||||
* Жизненный цикл документа фиксируется блокчейн-событиями контракта soviet:
|
||||
* - newsubmitted → Submitted
|
||||
* - newresolved → Resolved
|
||||
* - newdeclined → Declined
|
||||
*
|
||||
* Resolved — основной статус (на нём построено отображение реестра).
|
||||
* Submitted — отправлен/подписан, но ещё не утверждён (сценарий доп. утверждения;
|
||||
* сейчас фактически не используется — кандидат на вырезание, см. задачу C28-21).
|
||||
* Declined — отклонён/аннулирован.
|
||||
*/
|
||||
export enum SignedDocumentStatus {
|
||||
Submitted = 'submitted',
|
||||
Resolved = 'resolved',
|
||||
Declined = 'declined',
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { SignedDocumentStatus } from '../enums/signed-document-status.enum';
|
||||
import type { DocumentPackageAggregateDomainInterface } from '../interfaces/document-package-aggregate-domain.interface';
|
||||
|
||||
/**
|
||||
* Данные для записи/обновления одной записи реестра подписанных документов.
|
||||
* Одна запись = ОДИН документ (ключ — `doc_hash`, идентичность содержимого без подписей).
|
||||
* `hash` — крайняя подписанная версия (накопленные подписи), `packageHash` — идентификатор процесса,
|
||||
* общий для нескольких заявлений пакета (submitted↔resolved↔declined трекаются по `doc_hash`).
|
||||
*/
|
||||
export interface SignedDocumentUpsertInput {
|
||||
coopname: string;
|
||||
/** checksum256 процесса (`package` в blockchain-action; НЕ уникален — группирует документы) */
|
||||
packageHash: string;
|
||||
/** checksum256 содержимого документа — УНИКАЛЬНЫЙ ключ записи (без учёта подписей) */
|
||||
doc_hash: string;
|
||||
/** checksum256 подписанной версии (включает подписи) — крайняя версия документа */
|
||||
hash: string;
|
||||
username: string;
|
||||
status: SignedDocumentStatus;
|
||||
/** под-действие soviet (`data.action`) — для фильтрации списка по типам документов */
|
||||
action: string;
|
||||
registry_id: number;
|
||||
full_title: string;
|
||||
/** html заявления без тегов — для ILIKE-поиска */
|
||||
content_text: string;
|
||||
/** ФИО/наименования всех подписантов пакета + их username — для поиска по подписанту */
|
||||
signers_text: string;
|
||||
/** bigint из блокчейна приходит строкой */
|
||||
block_num: string | null;
|
||||
document_created_at: Date | null;
|
||||
/** готовый агрегат пакета (statement/decision/acts/links) — отдаётся getDocuments как есть */
|
||||
document_aggregate: DocumentPackageAggregateDomainInterface | null;
|
||||
/** data действия-носителя заявления — для пересборки агрегата при доприходе decision/act/link */
|
||||
source_action_data: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface SignedDocumentSearchParams {
|
||||
coopname: string;
|
||||
query: string;
|
||||
limit: number;
|
||||
/** ограничить поиск документами этого пайщика (для не-членов совета); undefined — весь кооператив */
|
||||
username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат поиска — повторяет контракт SearchResultDTO (GraphQL `searchDocuments`).
|
||||
*/
|
||||
export interface SignedDocumentSearchHit {
|
||||
hash: string;
|
||||
/** Чистое наименование документа (meta.title), без служебного хвоста ` - - дата.pdf`. */
|
||||
full_title: string;
|
||||
username: string;
|
||||
/** ФИО подписанта-субъекта («Иванов Иван Иванович») для отображения вместо username. */
|
||||
signer: string;
|
||||
coopname: string;
|
||||
registry_id: number;
|
||||
created_at: string | null;
|
||||
highlights: string[];
|
||||
}
|
||||
|
||||
/** Параметры выборки готовых агрегатов для списка документов (read-path getDocuments). */
|
||||
export interface SignedDocumentListParams {
|
||||
coopname: string;
|
||||
status?: SignedDocumentStatus;
|
||||
/** фильтр по под-действиям (data.action) */
|
||||
actions?: string[];
|
||||
/** фильтр по пайщику */
|
||||
username?: string;
|
||||
/** фильтр по хэшу конкретного документа-заявления */
|
||||
hash?: string;
|
||||
afterBlock?: number;
|
||||
beforeBlock?: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface SignedDocumentListResult {
|
||||
items: DocumentPackageAggregateDomainInterface[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Частичное обновление записи при ПЕРЕСБОРКЕ агрегата (доприход decision/act/link).
|
||||
* Затрагивает ТОЛЬКО агрегат-производные колонки. Поля жизненного цикла (status, block_num,
|
||||
* source_action_data, document_created_at) сюда НЕ входят: ими владеют статус-события
|
||||
* (newsubmitted/newresolved/newdeclined). Иначе пересборка, читающая старый row.status, откатила бы
|
||||
* параллельно проставленный resolved обратно в submitted (гонка freedecision: newresolved+newdecision
|
||||
* по одному package приходят одновременно).
|
||||
*/
|
||||
export interface SignedDocumentAggregateUpdate {
|
||||
document_aggregate: DocumentPackageAggregateDomainInterface | null;
|
||||
full_title: string;
|
||||
content_text: string;
|
||||
signers_text: string;
|
||||
}
|
||||
|
||||
/** Состояние записи для guard'ов ingestion (не понижать статус / не откатывать версию подписи). */
|
||||
export interface SignedDocumentState {
|
||||
status: SignedDocumentStatus;
|
||||
/** номер блока крайней версии (для сравнения «новее ли пришедшая версия») */
|
||||
blockNum: number | null;
|
||||
}
|
||||
|
||||
/** Строка реестра для пересборки агрегата: документ пакета + его действие-носитель. */
|
||||
export interface SignedDocumentPackageRow {
|
||||
doc_hash: string;
|
||||
hash: string;
|
||||
status: SignedDocumentStatus;
|
||||
sourceActionData: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Репозиторий реестра подписанных документов (Postgres-проекция).
|
||||
* Единый источник для отображения (getDocuments) и поиска (searchDocuments) — задача C28-21.
|
||||
* Запись = один документ (ключ `doc_hash`); `package` группирует заявления одного процесса.
|
||||
*/
|
||||
export interface SignedDocumentRepository {
|
||||
/** Вставить или обновить запись по (coopname, doc_hash). */
|
||||
upsert(input: SignedDocumentUpsertInput): Promise<void>;
|
||||
|
||||
/** Частично обновить ТОЛЬКО агрегат-производные колонки записи (по coopname, doc_hash).
|
||||
* Используется пересборкой при доприходе decision/act/link — status/block_num/source НЕ трогаются
|
||||
* (см. SignedDocumentAggregateUpdate: защита от отката resolved→submitted в гонке). */
|
||||
updateAggregate(coopname: string, docHash: string, fields: SignedDocumentAggregateUpdate): Promise<void>;
|
||||
|
||||
/** Статус + номер блока крайней версии документа по его doc_hash (для guard'ов ingestion);
|
||||
* null, если записи нет. */
|
||||
getState(coopname: string, docHash: string): Promise<SignedDocumentState | null>;
|
||||
|
||||
/** Кол-во записей реестра по кооперативу (для авто-триггера backfill на пустой базе). */
|
||||
count(coopname: string): Promise<number>;
|
||||
|
||||
/** Все заявления одного процесса (package) — для пересборки агрегатов при доприходе
|
||||
* decision/act/link (на пакет может приходиться несколько заявлений с разными подписантами). */
|
||||
findByPackage(coopname: string, packageHash: string): Promise<SignedDocumentPackageRow[]>;
|
||||
|
||||
/** Поиск по реестру (ILIKE по signers_text/full_title/content_text/username). */
|
||||
search(params: SignedDocumentSearchParams): Promise<SignedDocumentSearchHit[]>;
|
||||
|
||||
/** Список готовых агрегатов с пагинацией и фильтрами (read-path getDocuments). */
|
||||
findAggregates(params: SignedDocumentListParams): Promise<SignedDocumentListResult>;
|
||||
}
|
||||
|
||||
/** Токен для инъекции зависимости репозитория реестра подписанных документов. */
|
||||
export const SIGNED_DOCUMENT_REPOSITORY = Symbol('SignedDocumentRepository');
|
||||
@@ -11,10 +11,12 @@ import type { ActionRepositoryPort } from '../ports/action-repository.port';
|
||||
import type { DeltaRepositoryPort } from '../ports/delta-repository.port';
|
||||
import type { ForkRepositoryPort } from '../ports/fork-repository.port';
|
||||
import type { SyncStateRepositoryPort } from '../ports/sync-state-repository.port';
|
||||
import type { ConsumerDedupRepositoryPort } from '../ports/consumer-dedup-repository.port';
|
||||
import { ACTION_REPOSITORY_PORT } from '../ports/action-repository.port';
|
||||
import { DELTA_REPOSITORY_PORT } from '../ports/delta-repository.port';
|
||||
import { FORK_REPOSITORY_PORT } from '../ports/fork-repository.port';
|
||||
import { SYNC_STATE_REPOSITORY_PORT } from '../ports/sync-state-repository.port';
|
||||
import { CONSUMER_DEDUP_REPOSITORY_PORT } from '../ports/consumer-dedup-repository.port';
|
||||
|
||||
/**
|
||||
* Интерактор парсера блокчейна
|
||||
@@ -30,9 +32,36 @@ export class ParserInteractor {
|
||||
@Inject(FORK_REPOSITORY_PORT)
|
||||
private readonly forkRepository: ForkRepositoryPort,
|
||||
@Inject(SYNC_STATE_REPOSITORY_PORT)
|
||||
private readonly syncStateRepository: SyncStateRepositoryPort
|
||||
private readonly syncStateRepository: SyncStateRepositoryPort,
|
||||
@Inject(CONSUMER_DEDUP_REPOSITORY_PORT)
|
||||
private readonly consumerDedupRepository: ConsumerDedupRepositoryPort
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Отмечено ли событие как уже применённое (Story 2.3, INV-09).
|
||||
*/
|
||||
async isEventApplied(eventId: string): Promise<boolean> {
|
||||
return await this.consumerDedupRepository.isApplied(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отметить событие применённым в consumer_dedup (Story 2.2 dual-write).
|
||||
* Идемпотентно: повтор после краха между save и mark не падает.
|
||||
* blockNum (Story 4.1) — для последующего deleteAfterBlock при форке;
|
||||
* опциональный, если вызов из мест без контекста блока.
|
||||
*/
|
||||
async markEventApplied(eventId: string, blockNum?: number): Promise<void> {
|
||||
await this.consumerDedupRepository.markApplied(eventId, blockNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить из consumer_dedup записи с block_num > forkBlockNum — очистка дедупа
|
||||
* на форке (Story 4.1, ADR-005). Возвращает число удалённых строк.
|
||||
*/
|
||||
async deleteDedupAfterBlock(forkBlockNum: number): Promise<number> {
|
||||
return await this.consumerDedupRepository.deleteAfterBlock(forkBlockNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение действия блокчейна
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Порт списка применённых событий (consumer_dedup) — фундамент идемпотентности
|
||||
* (Story 2.1, INV-09).
|
||||
*/
|
||||
export interface ConsumerDedupRepositoryPort {
|
||||
/** Отмечено ли событие как уже применённое. Используется dedup-gate (Story 2.3). */
|
||||
isApplied(eventId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Отметить событие применённым. Идемпотентно (ON CONFLICT DO NOTHING): повтор
|
||||
* после краха между save и mark не должен падать. blockNum — номер блока события
|
||||
* (Story 4.1, для последующего deleteAfterBlock на форке); опциональный для
|
||||
* backward-compat с местами, где блок неизвестен (например, ручные тесты).
|
||||
*/
|
||||
markApplied(eventId: string, blockNum?: number): Promise<void>;
|
||||
|
||||
/** Очистка меток старше cutoff (retention). Возвращает число удалённых строк. */
|
||||
deleteOlderThan(cutoff: Date): Promise<number>;
|
||||
|
||||
/**
|
||||
* Удалить метки событий с block_num > blockNum — для очистки дедупа на форке
|
||||
* (Story 4.1, ADR-005). Записи с NULL block_num (legacy до Epic 4) НЕ затрагиваются:
|
||||
* PG сравнение NULL > N даёт unknown и не попадает под WHERE. Возвращает число строк.
|
||||
*/
|
||||
deleteAfterBlock(blockNum: number): Promise<number>;
|
||||
}
|
||||
|
||||
export const CONSUMER_DEDUP_REPOSITORY_PORT = Symbol('ConsumerDedupRepositoryPort');
|
||||
@@ -94,27 +94,4 @@ export class BlockchainEventHandlerService implements OnModuleInit {
|
||||
throw error; // Перебрасываем ошибку для корректной обработки
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка события форка блокчейна
|
||||
* Сохраняет форк в базу данных
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleForkEvent(data: { block_num: number }): Promise<void> {
|
||||
try {
|
||||
this.logger.debug(`Handling fork event at block: ${data.block_num}`);
|
||||
|
||||
// Преобразование данных форка для сохранения
|
||||
const forkData = {
|
||||
chain_id: config.blockchain.id, // Используем chain_id из конфигурации
|
||||
block_num: data.block_num,
|
||||
};
|
||||
|
||||
await this.parserInteractor.saveFork(forkData);
|
||||
this.logger.debug(`Fork saved at block: ${data.block_num} for chain: ${config.blockchain.id}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка обработки события форка: ${error.message}`, error.stack);
|
||||
throw error; // Перебрасываем ошибку для корректной обработки
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Header,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { HttpJwtAuthGuard } from '~/application/auth/guards/http-jwt-auth.guard';
|
||||
import { AppsCatalogHttpService } from '../../infrastructure/apps-catalog-http.service';
|
||||
|
||||
const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
/**
|
||||
* Story 9.4.b — REST-проксирование install.js пакета из apps-catalog к
|
||||
* desktop'у. Защищён HTTP JWT — пайщик должен быть залогинен в свой
|
||||
* кооператив, иначе нельзя загружать remote-расширения. Admin-API ключ
|
||||
* apps-catalog'а инкапсулирован в backend'е и не утекает в браузер
|
||||
* (см. architecture-choice C в Epic 9.5.b).
|
||||
*
|
||||
* Endpoint:
|
||||
* `GET /v1/apps-catalog/install/:scope/:name`
|
||||
* → 200 text/javascript — CJS install.js
|
||||
* → 404 если пакета нет (или ca-admin недоступен в degraded mode).
|
||||
* → 401 без JWT.
|
||||
*/
|
||||
@Controller('v1/apps-catalog/install')
|
||||
@UseGuards(HttpJwtAuthGuard)
|
||||
export class AppsCatalogInstallScriptController {
|
||||
constructor(private readonly catalog: AppsCatalogHttpService) {}
|
||||
|
||||
@Get(':scope/:name')
|
||||
@Header('Content-Type', 'application/javascript; charset=utf-8')
|
||||
@Header('Cache-Control', 'no-store')
|
||||
async execute(
|
||||
@Param('scope') scope: string,
|
||||
@Param('name') name: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<string> {
|
||||
if (!NAME_REGEX.test(scope) || !NAME_REGEX.test(name)) {
|
||||
throw new HttpException(
|
||||
'Invalid package coordinates',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const code = await this.catalog.fetchInstallScript(scope, name);
|
||||
if (code === null) {
|
||||
throw new HttpException(
|
||||
'install.js not available',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
res.status(HttpStatus.OK);
|
||||
return code;
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* GraphQL-проекция одного remote-пакета в публичном каталоге apps-catalog
|
||||
* (Story 9.5.b). Источник — `GET /v1/public/packages` на ca-admin, который
|
||||
* controller проксирует через `Query.appsCatalogRemotePackages`. На фронте
|
||||
* подписи витрины строятся из этих полей; фильтр self/чужое и self-sub
|
||||
* bypass делает desktop по `publisher === coopname`.
|
||||
*/
|
||||
@ObjectType()
|
||||
export class AppsCatalogRemotePackageDTO {
|
||||
@Field({ description: 'Идентификатор пакета (например, @voskhod/demoapp)' })
|
||||
packageId!: string;
|
||||
|
||||
@Field({ description: 'Имя владельца пакета (кооператив-разработчик)' })
|
||||
publisher!: string;
|
||||
|
||||
@Field(() => [String], {
|
||||
description: 'Совместимые subnet (chain_id блокчейна ЦК)',
|
||||
})
|
||||
compatibleSubnets!: string[];
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Последняя активная версия (semver). null = релизов ещё нет.',
|
||||
})
|
||||
lastActiveVersion?: string | null;
|
||||
|
||||
@Field({
|
||||
description: 'Заголовок пакета для UI (в MVP — packageId, в будущем из manifest)',
|
||||
})
|
||||
title!: string;
|
||||
|
||||
@Field({
|
||||
description: 'Краткое описание (в MVP — заглушка, в будущем из manifest)',
|
||||
})
|
||||
description!: string;
|
||||
|
||||
@Field({
|
||||
description: 'Стоимость подписки, RUB/месяц (в MVP — фиксированно из dev-pricing seed)',
|
||||
})
|
||||
rubPerMonth!: number;
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Входные данные `Mutation.publishPackage` (story 9.3.b-pub).
|
||||
*
|
||||
* Эта мутация — первичная регистрация пакета в каталоге восхода. Она
|
||||
* прокидывается на ca-admin `POST /v1/admin/package` (action
|
||||
* `apps::regpkg` на blockchain), а ca-admin сам подписывает on-chain от
|
||||
* имени chairman'а кооператива-оператора (voskhod). Поэтому здесь нет
|
||||
* никаких ключей пайщика — только координаты пакета.
|
||||
*
|
||||
* Multipart upload install.js под Federation v2 более НЕ нужен:
|
||||
* расширение публикуется как npm-package + docker-image в Nexus, а
|
||||
* developer пушит их отдельной процедурой (`npm publish` + `docker
|
||||
* push`) через CA-auth scoped JWT. Story 9.3.b-pub оставляет только
|
||||
* on-chain регистрацию; релизы с manifest'ом — story 9.3.b-rel.
|
||||
*/
|
||||
@InputType()
|
||||
export class PublishPackageInputDTO {
|
||||
@Field({
|
||||
description:
|
||||
'Идентификатор пакета в формате @scope/name, например @voskhod/demoapp',
|
||||
})
|
||||
packageId!: string;
|
||||
|
||||
@Field({
|
||||
description:
|
||||
'Antelope-имя владельца пакета (1..12 символов из [a-z1-5.])',
|
||||
})
|
||||
ownerUsername!: string;
|
||||
|
||||
@Field(() => [String], {
|
||||
description:
|
||||
'chain_id совместимых подсетей (каждый — 64 hex-символа), хотя бы одна',
|
||||
})
|
||||
compatibleSubnets!: string[];
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Результат `Mutation.publishPackage` (story 9.3.b-pub).
|
||||
*
|
||||
* Discriminator `status` — `applied` если ca-admin вернул 200 (on-chain
|
||||
* `apps::regpkg` улетел и подтверждён), `conflict` если пакет уже
|
||||
* зарегистрирован (HTTP 409), `failed` для остальных ошибок (включая
|
||||
* degraded-mode когда APPS_CATALOG_API_KEY не задан).
|
||||
*/
|
||||
@ObjectType()
|
||||
export class PublishPackageResultDTO {
|
||||
@Field({
|
||||
description:
|
||||
'Статус: applied (зарегистрирован on-chain), conflict (уже есть), failed (ошибка)',
|
||||
})
|
||||
status!: 'applied' | 'conflict' | 'failed';
|
||||
|
||||
@Field({
|
||||
description:
|
||||
'Идентификатор запроса (UUIDv4), который ca-admin использовал для идемпотентности',
|
||||
})
|
||||
requestId!: string;
|
||||
|
||||
@Field({ nullable: true, description: 'Человекочитаемое сообщение об ошибке' })
|
||||
error?: string;
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
|
||||
/**
|
||||
* Входные данные `Mutation.publishRelease` (story 9.3.b-rel).
|
||||
*
|
||||
* Эта мутация регистрирует новый релиз ранее зарегистрированного
|
||||
* пакета (см. `publishPackage`/9.3.b-pub). Прокидывается на ca-admin
|
||||
* `POST /v1/admin/releases` (action `apps::setrelease` on-chain), который
|
||||
* сам подписывает от имени chairman'а кооператива-оператора.
|
||||
*
|
||||
* `manifest` под архитектуру E10 содержит ссылки на артефакты в Nexus:
|
||||
* `coopenomics.backend.image` — docker-image (для subgraph'а
|
||||
* расширения), `coopenomics.frontend.tarball` — npm tarball (для
|
||||
* desktop-bundle). Сам manifest валидируется Zod-схемой
|
||||
* `PackageManifestSchema` на стороне ca-admin (HTTP 422 → resolver
|
||||
* мапит в `failed`).
|
||||
*/
|
||||
@InputType()
|
||||
export class PublishReleaseInputDTO {
|
||||
@Field({
|
||||
description:
|
||||
'Идентификатор пакета в формате @scope/name (должен быть уже зарегистрирован через publishPackage)',
|
||||
})
|
||||
packageId!: string;
|
||||
|
||||
@Field({ description: 'Версия релиза в формате semver, например 1.0.0' })
|
||||
version!: string;
|
||||
|
||||
@Field(() => GraphQLJSON, {
|
||||
description:
|
||||
'Package manifest (валидируется Zod-схемой на ca-admin). ' +
|
||||
'Содержит coopenomics.backend.image (docker) + coopenomics.frontend.tarball (npm), ' +
|
||||
'requires/provides, GraphQL-схему и pricing-параметры.',
|
||||
})
|
||||
manifest!: Record<string, unknown>;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
'sha256 npm tarball\'а (HEX). Если не передан — ca-admin использует sentinel zero-hash.',
|
||||
})
|
||||
tarballSha256?: string;
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Результат `Mutation.publishRelease` (story 9.3.b-rel).
|
||||
*
|
||||
* Discriminator `status`:
|
||||
* - `applied` — ca-admin вернул 200, on-chain `apps::setrelease` улетел;
|
||||
* - `invalidManifest` — HTTP 422 INVALID_MANIFEST (Zod-валидация
|
||||
* manifest'а провалилась на стороне ca-admin); UI должен показать
|
||||
* `error` как валидационное сообщение;
|
||||
* - `failed` — прочие ошибки (network, 401, degraded-mode без
|
||||
* APPS_CATALOG_API_KEY).
|
||||
*/
|
||||
@ObjectType()
|
||||
export class PublishReleaseResultDTO {
|
||||
@Field({
|
||||
description:
|
||||
'Статус: applied | invalidManifest | failed',
|
||||
})
|
||||
status!: 'applied' | 'invalidManifest' | 'failed';
|
||||
|
||||
@Field({
|
||||
description:
|
||||
'Идентификатор запроса (UUIDv4), который ca-admin использовал для идемпотентности',
|
||||
})
|
||||
requestId!: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
'Идентификатор blockchain-транзакции (если ca-admin её вернул)',
|
||||
})
|
||||
transactionId?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Человекочитаемое сообщение об ошибке',
|
||||
})
|
||||
error?: string;
|
||||
}
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { Logger, UseGuards } from '@nestjs/common';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { AppsCatalogHttpService } from '../../infrastructure/apps-catalog-http.service';
|
||||
import { AppsCatalogRemotePackageDTO } from '../dto/apps-catalog-remote-package.dto';
|
||||
import { PublishPackageInputDTO } from '../dto/publish-package-input.dto';
|
||||
import { PublishPackageResultDTO } from '../dto/publish-package-result.dto';
|
||||
import { PublishReleaseInputDTO } from '../dto/publish-release-input.dto';
|
||||
import { PublishReleaseResultDTO } from '../dto/publish-release-result.dto';
|
||||
|
||||
/**
|
||||
* Story 9.5.b — публичный каталог remote-пакетов на desktop'е магазина.
|
||||
*
|
||||
* Прокси-резолвер: controller ходит к ca-admin /v1/public/packages, маппит
|
||||
* snake_case wire-формат в camelCase GraphQL-проекцию, добавляет UI-поля
|
||||
* (title/description/rubPerMonth) для витрины. В V1 description и
|
||||
* rubPerMonth — заглушки; в V2 будут читаться из package manifest и
|
||||
* apps-catalog pricing соответственно.
|
||||
*
|
||||
* Guard: GqlJwtAuthGuard — каталог видят только авторизованные пайщики
|
||||
* (по умолчанию роль `user`). Self-filter и self-sub bypass делает
|
||||
* desktop по сравнению `publisher` и текущего coopname.
|
||||
*/
|
||||
@Resolver()
|
||||
export class AppsCatalogProxyResolver {
|
||||
private readonly logger = new Logger(AppsCatalogProxyResolver.name);
|
||||
|
||||
constructor(private readonly client: AppsCatalogHttpService) {}
|
||||
|
||||
@Query(() => [AppsCatalogRemotePackageDTO], {
|
||||
name: 'appsCatalogRemotePackages',
|
||||
description:
|
||||
'Список remote-пакетов из публичного каталога apps-catalog. ' +
|
||||
'Защищён JWT (видят только авторизованные пайщики). ' +
|
||||
'Источник — ca-admin /v1/public/packages; controller проксирует.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard)
|
||||
async appsCatalogRemotePackages(
|
||||
@Args('page', { type: () => Number, defaultValue: 1 }) page: number,
|
||||
@Args('pageSize', { type: () => Number, defaultValue: 50 }) pageSize: number,
|
||||
): Promise<AppsCatalogRemotePackageDTO[]> {
|
||||
const packages = await this.client.listPublicPackages(page, pageSize);
|
||||
return packages.map((p) => ({
|
||||
packageId: p.packageId,
|
||||
publisher: p.publisher,
|
||||
compatibleSubnets: p.compatibleSubnets,
|
||||
lastActiveVersion: p.lastActiveVersion,
|
||||
title: this.buildTitle(p.packageId),
|
||||
description: this.buildDescription(p.publisher),
|
||||
rubPerMonth: 1000,
|
||||
}));
|
||||
}
|
||||
|
||||
private buildTitle(packageId: string): string {
|
||||
const tail = packageId.split('/').pop() ?? packageId;
|
||||
return tail.charAt(0).toUpperCase() + tail.slice(1);
|
||||
}
|
||||
|
||||
private buildDescription(publisher: string): string {
|
||||
return `Удалённое расширение от ${publisher} — устанавливается без перезагрузки сервера.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Story 9.3.b-pub — стол разработчика публикует пакет в каталоге восхода.
|
||||
*
|
||||
* Защита: только chairman кооператива-оператора (`voskhod` на dev).
|
||||
* Сама подпись on-chain `apps::regpkg` делает ca-admin от имени chairman'а
|
||||
* восхода — controller только проксирует HTTP-запрос и возвращает
|
||||
* discriminated outcome (`applied | conflict | failed`).
|
||||
*
|
||||
* Multipart upload install.js здесь НЕ делается. Под архитектуру E10
|
||||
* расширения публикуются как npm-package + docker-image в Nexus
|
||||
* отдельной процедурой (`npm publish` / `docker push`) через scoped
|
||||
* JWT от CA-auth. Эта мутация — только on-chain маркер «такой
|
||||
* пакет существует».
|
||||
*/
|
||||
@Mutation(() => PublishPackageResultDTO, {
|
||||
name: 'publishPackage',
|
||||
description:
|
||||
'Регистрирует пакет on-chain (action apps::regpkg) через ca-admin. ' +
|
||||
'Подписывает chairman кооператива-оператора каталога. Доступно ' +
|
||||
'только chairman\'у (стол разработчика).',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman'])
|
||||
async publishPackage(
|
||||
@Args('data', { type: () => PublishPackageInputDTO })
|
||||
data: PublishPackageInputDTO,
|
||||
): Promise<PublishPackageResultDTO> {
|
||||
const outcome = await this.client.registerPackage({
|
||||
packageId: data.packageId,
|
||||
ownerUsername: data.ownerUsername,
|
||||
compatibleSubnets: data.compatibleSubnets,
|
||||
});
|
||||
if (outcome.status === 'applied') {
|
||||
this.logger.log(
|
||||
`publishPackage applied: ${data.packageId} (request ${outcome.requestId})`,
|
||||
);
|
||||
return { status: 'applied', requestId: outcome.requestId };
|
||||
}
|
||||
if (outcome.status === 'conflict') {
|
||||
return {
|
||||
status: 'conflict',
|
||||
requestId: outcome.requestId,
|
||||
error: outcome.error,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'failed',
|
||||
requestId: outcome.requestId,
|
||||
error: outcome.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Story 9.3.b-rel — стол разработчика выкладывает новый релиз пакета.
|
||||
*
|
||||
* Прокидывает manifest + версию на ca-admin `POST /v1/admin/releases`.
|
||||
* ca-admin валидирует manifest Zod-схемой и подписывает on-chain
|
||||
* `apps::setrelease` от имени chairman'а кооператива-оператора. Под
|
||||
* архитектуру E10 manifest должен содержать ссылки на артефакты в
|
||||
* Nexus (`coopenomics.backend.image` + `coopenomics.frontend.tarball`).
|
||||
*/
|
||||
@Mutation(() => PublishReleaseResultDTO, {
|
||||
name: 'publishRelease',
|
||||
description:
|
||||
'Создаёт новый релиз пакета (action apps::setrelease) через ca-admin. ' +
|
||||
'Подписывает chairman кооператива-оператора каталога. Доступно ' +
|
||||
'только chairman\'у (стол разработчика).',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman'])
|
||||
async publishRelease(
|
||||
@Args('data', { type: () => PublishReleaseInputDTO })
|
||||
data: PublishReleaseInputDTO,
|
||||
): Promise<PublishReleaseResultDTO> {
|
||||
const outcome = await this.client.createRelease({
|
||||
packageId: data.packageId,
|
||||
version: data.version,
|
||||
manifest: data.manifest,
|
||||
tarballSha256: data.tarballSha256,
|
||||
});
|
||||
if (outcome.status === 'applied') {
|
||||
this.logger.log(
|
||||
`publishRelease applied: ${data.packageId}@${data.version} (request ${outcome.requestId}, tx ${outcome.transactionId ?? '-'})`,
|
||||
);
|
||||
return {
|
||||
status: 'applied',
|
||||
requestId: outcome.requestId,
|
||||
transactionId: outcome.transactionId,
|
||||
};
|
||||
}
|
||||
if (outcome.status === 'invalidManifest') {
|
||||
return {
|
||||
status: 'invalidManifest',
|
||||
requestId: outcome.requestId,
|
||||
error: outcome.error,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'failed',
|
||||
requestId: outcome.requestId,
|
||||
error: outcome.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppsCatalogHttpService } from './infrastructure/apps-catalog-http.service';
|
||||
import { AppsCatalogInstallScriptController } from './application/controllers/install-script.controller';
|
||||
import { AppsCatalogProxyResolver } from './application/resolvers/apps-catalog-proxy.resolver';
|
||||
|
||||
/**
|
||||
* Story 9.5.b + 9.4.b — модуль-прокси для публичного каталога apps-catalog.
|
||||
*
|
||||
* GraphQL `Query.appsCatalogRemotePackages` (9.5.b) + REST
|
||||
* `GET /v1/apps-catalog/install/:scope/:name` (9.4.b) + HTTP-клиент к
|
||||
* ca-admin. Никакого state, никаких миграций, без TypeORM. Включается в
|
||||
* `ExtensionsModule` в общий imports/exports список.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AppsCatalogInstallScriptController],
|
||||
providers: [AppsCatalogHttpService, AppsCatalogProxyResolver],
|
||||
exports: [AppsCatalogHttpService],
|
||||
})
|
||||
export class AppsCatalogProxyModule {}
|
||||
-248
@@ -1,248 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios, { type AxiosInstance, AxiosError } from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import config from '~/config/config';
|
||||
|
||||
interface PublicPackageWireFormat {
|
||||
package_id: string;
|
||||
owner_username: string;
|
||||
compatible_subnets: string[];
|
||||
last_active_version: string | null;
|
||||
}
|
||||
|
||||
export interface AppsCatalogPackage {
|
||||
packageId: string;
|
||||
publisher: string;
|
||||
compatibleSubnets: string[];
|
||||
lastActiveVersion: string | null;
|
||||
}
|
||||
|
||||
export interface RegisterPackageInput {
|
||||
packageId: string;
|
||||
ownerUsername: string;
|
||||
compatibleSubnets: string[];
|
||||
/** Optional. Если не передан — генерируется uuidv4. */
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export type RegisterPackageOutcome =
|
||||
| { status: 'applied'; requestId: string }
|
||||
| { status: 'conflict'; requestId: string; error: string }
|
||||
| { status: 'failed'; requestId: string; error: string };
|
||||
|
||||
export interface CreateReleaseInput {
|
||||
packageId: string;
|
||||
version: string;
|
||||
manifest: Record<string, unknown>;
|
||||
tarballSha256?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export type CreateReleaseOutcome =
|
||||
| { status: 'applied'; requestId: string; transactionId?: string }
|
||||
| { status: 'invalidManifest'; requestId: string; error: string }
|
||||
| { status: 'failed'; requestId: string; error: string };
|
||||
|
||||
/**
|
||||
* HTTP-клиент к ca-admin (apps-catalog) для Story 9.5.b. Защищён admin-API
|
||||
* ключом из env (APPS_CATALOG_API_KEY). Используется только сервером —
|
||||
* ключ в браузер не утекает.
|
||||
*
|
||||
* Degraded mode: если APPS_CATALOG_URL / APPS_CATALOG_API_KEY не заданы —
|
||||
* возвращает пустой список вместо ошибки, чтобы dev-окружение без
|
||||
* apps-catalog (или CI без сети) не валило desktop boot.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppsCatalogHttpService {
|
||||
private readonly logger = new Logger(AppsCatalogHttpService.name);
|
||||
private readonly client: AxiosInstance | null;
|
||||
|
||||
constructor() {
|
||||
if (config.apps_catalog.url && config.apps_catalog.api_key) {
|
||||
this.client = axios.create({
|
||||
baseURL: config.apps_catalog.url,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apps_catalog.api_key}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.client = null;
|
||||
this.logger.warn(
|
||||
'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы — apps-catalog-proxy в degraded mode (пустой каталог)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listPublicPackages(page = 1, pageSize = 50): Promise<AppsCatalogPackage[]> {
|
||||
if (!this.client) return [];
|
||||
try {
|
||||
const res = await this.client.get<PublicPackageWireFormat[]>(
|
||||
'/v1/public/packages',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return res.data.map((p) => ({
|
||||
packageId: p.package_id,
|
||||
publisher: p.owner_username,
|
||||
compatibleSubnets: p.compatible_subnets,
|
||||
lastActiveVersion: p.last_active_version,
|
||||
}));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`apps-catalog list failed: ${msg}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Story 9.4.b: получить install.js пакета remote-расширения.
|
||||
*
|
||||
* Возвращает plain CJS-текст из ca-admin endpoint'а
|
||||
* `GET /v1/public/packages/:scope/:name/install.js`. На degraded mode
|
||||
* (без env-config'а apps-catalog'а) либо если ca-admin отвечает 404
|
||||
* — возвращает `null`, чтобы desktop'у было ясно «нет такого пакета /
|
||||
* каталог недоступен» вместо ошибки 500 в браузере.
|
||||
*
|
||||
* @param scope владелец пакета (`voskhod` из `@voskhod/demo-app`).
|
||||
* @param name short-name пакета (`demo-app`).
|
||||
*/
|
||||
/**
|
||||
* Story 9.3.b-pub: первичная регистрация пакета в каталоге.
|
||||
*
|
||||
* Прокидывает входной DTO на ca-admin `POST /v1/admin/package`. ca-admin
|
||||
* сам подписывает on-chain action `apps::regpkg` от имени chairman'а
|
||||
* кооператива-оператора каталога (voskhod на dev) — поэтому здесь не
|
||||
* требуется session-key пайщика.
|
||||
*
|
||||
* Discriminated outcome (vs. throw):
|
||||
* - `applied` — HTTP 200, on-chain transaction подтверждён;
|
||||
* - `conflict` — HTTP 409 (пакет уже зарегистрирован или дубликат
|
||||
* `request_id`); resolver мапит на `status: 'conflict'`;
|
||||
* - `failed` — любое прочее (network, 400, 401, degraded-mode без
|
||||
* APPS_CATALOG_API_KEY); resolver мапит на `status: 'failed'`.
|
||||
*
|
||||
* Degraded mode (нет client'а) — возвращает `failed` с явным сообщением:
|
||||
* mutation в degraded-стенде не делает silent no-op, чтобы UI не
|
||||
* вводился в заблуждение «пакет опубликован».
|
||||
*/
|
||||
async registerPackage(
|
||||
input: RegisterPackageInput,
|
||||
): Promise<RegisterPackageOutcome> {
|
||||
const requestId = input.requestId ?? uuidv4();
|
||||
if (!this.client) {
|
||||
const error = 'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы';
|
||||
this.logger.warn(
|
||||
`registerPackage refused (degraded mode): ${input.packageId}`,
|
||||
);
|
||||
return { status: 'failed', requestId, error };
|
||||
}
|
||||
try {
|
||||
await this.client.post('/v1/admin/package', {
|
||||
request_id: requestId,
|
||||
package_id: input.packageId,
|
||||
owner_username: input.ownerUsername,
|
||||
compatible_subnets: input.compatibleSubnets,
|
||||
});
|
||||
return { status: 'applied', requestId };
|
||||
} catch (err) {
|
||||
const status = (err as AxiosError).response?.status;
|
||||
const responseData = (err as AxiosError).response?.data;
|
||||
const detail =
|
||||
typeof responseData === 'object' && responseData
|
||||
? JSON.stringify(responseData)
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
if (status === 409) {
|
||||
this.logger.warn(
|
||||
`registerPackage conflict для ${input.packageId}: ${detail}`,
|
||||
);
|
||||
return { status: 'conflict', requestId, error: detail };
|
||||
}
|
||||
this.logger.error(
|
||||
`registerPackage failed для ${input.packageId}: ${detail}`,
|
||||
);
|
||||
return { status: 'failed', requestId, error: detail };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Story 9.3.b-rel: создание нового релиза пакета (action `apps::setrelease`).
|
||||
*
|
||||
* Прокидывает manifest + версию на ca-admin `POST /v1/admin/releases`.
|
||||
* ca-admin сам валидирует manifest Zod-схемой и подписывает on-chain.
|
||||
*
|
||||
* Discriminated outcome:
|
||||
* - `applied` — HTTP 200, on-chain прошёл; в payload может быть
|
||||
* `transaction_id` (если адаптер не stub'овый);
|
||||
* - `invalidManifest` — HTTP 422 INVALID_MANIFEST (manifest не прошёл
|
||||
* Zod-валидацию на стороне ca-admin); `error` содержит детали
|
||||
* валидации в стабильном формате;
|
||||
* - `failed` — прочие ошибки (network, 401, 503, degraded-mode).
|
||||
*/
|
||||
async createRelease(input: CreateReleaseInput): Promise<CreateReleaseOutcome> {
|
||||
const requestId = input.requestId ?? uuidv4();
|
||||
if (!this.client) {
|
||||
const error = 'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы';
|
||||
this.logger.warn(
|
||||
`createRelease refused (degraded mode): ${input.packageId}@${input.version}`,
|
||||
);
|
||||
return { status: 'failed', requestId, error };
|
||||
}
|
||||
try {
|
||||
const res = await this.client.post<{
|
||||
ok: boolean;
|
||||
transaction_id?: string;
|
||||
}>('/v1/admin/releases', {
|
||||
request_id: requestId,
|
||||
package_id: input.packageId,
|
||||
version: input.version,
|
||||
manifest: input.manifest,
|
||||
...(input.tarballSha256
|
||||
? { tarball_sha256: input.tarballSha256 }
|
||||
: {}),
|
||||
});
|
||||
return {
|
||||
status: 'applied',
|
||||
requestId,
|
||||
transactionId: res.data?.transaction_id,
|
||||
};
|
||||
} catch (err) {
|
||||
const status = (err as AxiosError).response?.status;
|
||||
const responseData = (err as AxiosError).response?.data;
|
||||
const detail =
|
||||
typeof responseData === 'object' && responseData
|
||||
? JSON.stringify(responseData)
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
if (status === 422) {
|
||||
this.logger.warn(
|
||||
`createRelease invalidManifest для ${input.packageId}@${input.version}: ${detail}`,
|
||||
);
|
||||
return { status: 'invalidManifest', requestId, error: detail };
|
||||
}
|
||||
this.logger.error(
|
||||
`createRelease failed для ${input.packageId}@${input.version}: ${detail}`,
|
||||
);
|
||||
return { status: 'failed', requestId, error: detail };
|
||||
}
|
||||
}
|
||||
|
||||
async fetchInstallScript(scope: string, name: string): Promise<string | null> {
|
||||
if (!this.client) return null;
|
||||
try {
|
||||
const res = await this.client.get<string>(
|
||||
`/v1/public/packages/${encodeURIComponent(scope)}/${encodeURIComponent(name)}/install.js`,
|
||||
{ responseType: 'text', transformResponse: (data) => data as string },
|
||||
);
|
||||
return typeof res.data === 'string' ? res.data : String(res.data);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`apps-catalog install.js fetch failed for ${scope}/${name}: ${msg}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
-8
@@ -75,12 +75,4 @@ export class AppendixSyncService
|
||||
this.logger.error(`Ошибка при обработке отклонения приложения: ${error?.message}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для приложений
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleAppendixFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -104,13 +104,4 @@ export class CommitSyncService
|
||||
this.logger.error(`Ошибка при обработке отклонения коммита: ${error?.message}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для коммитов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleCommitFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ContributorDomainEntity } from '../../domain/entities/contributor.entity';
|
||||
@@ -84,13 +84,4 @@ export class ContributorSyncService
|
||||
|
||||
return contributorEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для участников
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleContributorFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { DebtDomainEntity } from '../../domain/entities/debt.entity';
|
||||
@@ -49,13 +49,4 @@ export class DebtSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации долгов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для долгов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleDebtFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ExpenseDomainEntity } from '../../domain/entities/expense.entity';
|
||||
@@ -49,13 +49,4 @@ export class ExpenseSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации расходов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для расходов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleExpenseFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { InvestDomainEntity } from '../../domain/entities/invest.entity';
|
||||
@@ -49,13 +49,4 @@ export class InvestSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации инвестиций полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для инвестиций
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleInvestFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProgramPropertyDomainEntity } from '../../domain/entities/program-property.entity';
|
||||
@@ -50,12 +50,4 @@ export class ProgramPropertySyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для программных имущественных взносов
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProgramPropertyFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProgramWalletDomainEntity } from '../../domain/entities/program-wallet.entity';
|
||||
@@ -47,12 +47,4 @@ export class ProgramWalletSyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для программных кошельков
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProgramWalletFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProgramWithdrawDomainEntity } from '../../domain/entities/program-withdraw.entity';
|
||||
@@ -50,12 +50,4 @@ export class ProgramWithdrawSyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для возвратов из программы
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProgramWithdrawFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProjectPropertyDomainEntity } from '../../domain/entities/project-property.entity';
|
||||
@@ -50,12 +50,4 @@ export class ProjectPropertySyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для проектных имущественных взносов
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProjectPropertyFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProjectDomainEntity } from '../../domain/entities/project.entity';
|
||||
@@ -139,13 +139,4 @@ export class ProjectSyncService
|
||||
|
||||
return projectEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для проектов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProjectFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ResultDomainEntity } from '../../domain/entities/result.entity';
|
||||
@@ -102,13 +102,4 @@ export class ResultSyncService
|
||||
|
||||
return resultEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для результатов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleResultFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { SegmentDomainEntity } from '../../domain/entities/segment.entity';
|
||||
@@ -54,16 +54,6 @@ export class SegmentSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации сегментов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для сегментов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleSegmentFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация сегмента между блокчейном и базой данных
|
||||
*/
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { StateDomainEntity } from '../../domain/entities/state.entity';
|
||||
@@ -49,13 +49,4 @@ export class StateSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации состояния полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для состояния
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleStateFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { VoteDomainEntity } from '../../domain/entities/vote.entity';
|
||||
@@ -49,13 +49,4 @@ export class VoteSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации голосов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для голосов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleVoteFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import type {
|
||||
import type { IProjectDomainInterfaceBlockchainData } from '../interfaces/project-blockchain.interface';
|
||||
import type { IBlockchainSynchronizable } from '~/shared/interfaces/blockchain-sync.interface';
|
||||
import { BaseDomainEntity } from '~/shared/sync/entities/base-domain.entity';
|
||||
import { auditUnknownStatus } from '~/shared/sync/errors/audit-unknown-status';
|
||||
import { IssueIdGenerationService } from '../services/issue-id-generation.service';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
|
||||
const PROJECT_STATUS_AUDIT_LOGGER = new WinstonLoggerService();
|
||||
PROJECT_STATUS_AUDIT_LOGGER.setContext('ProjectDomainEntity');
|
||||
|
||||
/**
|
||||
* Доменная сущность проекта
|
||||
*
|
||||
@@ -210,7 +216,16 @@ export class ProjectDomainEntity
|
||||
case 'cancelled':
|
||||
return ProjectStatus.CANCELLED;
|
||||
default:
|
||||
// По умолчанию считаем статус неопределенным
|
||||
// Story 6.5: silent fallback на UNDEFINED заменён audit-trail'ом.
|
||||
// Если контракт ввёл новый статус — drift всплывёт в логе как error.
|
||||
auditUnknownStatus('ProjectDomainEntity', blockchainStatus, PROJECT_STATUS_AUDIT_LOGGER, [
|
||||
'pending',
|
||||
'active',
|
||||
'voting',
|
||||
'result',
|
||||
'finalized',
|
||||
'cancelled',
|
||||
]);
|
||||
return ProjectStatus.UNDEFINED;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -29,10 +29,9 @@ export class AppendixDeltaMapper extends AbstractBlockchainDeltaMapper<IAppendix
|
||||
return null;
|
||||
}
|
||||
|
||||
// 🔥 ВАЖНО: Парсим документы ПЕРЕД возвратом
|
||||
// Парсим документы ПЕРЕД возвратом
|
||||
const appendix = DomainToBlockchainUtils.convertChainDocumentToDomainFormat(value.appendix);
|
||||
|
||||
// Парсим документы
|
||||
return { ...value, appendix };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error mapping delta to blockchain data: ${error.message}`, error.stack);
|
||||
|
||||
+3
-11
@@ -256,20 +256,12 @@ export class DecisionExpiredNotificationService implements OnModuleInit, OnModul
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем дату последней проверки. ВАЖНО: read-modify-write по СВЕЖЕМУ
|
||||
// config из БД, а не по захваченному при initialize() снимку `plugin`.
|
||||
// Cron-замыкание держит in-memory снимок с момента boot; за время между
|
||||
// boot и тиком онбординг-флаги (`onboarding_*_done/_hash`) и прочие поля
|
||||
// могли быть записаны в БД другими сервисами. Перезапись устаревшего
|
||||
// снимка стёрла бы их (lost update). Поэтому берём актуальный config и
|
||||
// трогаем только lastCheckDate.
|
||||
const fresh = await this.extensionRepository.findByName('chairman');
|
||||
const baseConfig = fresh?.config ?? plugin.config;
|
||||
// Обновляем дату последней проверки (только это поле, чтобы не перезаписать другие параметры конфига)
|
||||
const updatedConfig = {
|
||||
...baseConfig,
|
||||
...plugin.config,
|
||||
lastCheckDate: new Date().toISOString(),
|
||||
};
|
||||
await this.extensionRepository.update({ name: 'chairman', config: updatedConfig });
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
|
||||
this.logger.debug('Проверка истекших решений завершена');
|
||||
} catch (error) {
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import type { IDelta } from '~/types/common';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../../shared/services/abstract-entity-sync.service';
|
||||
@@ -56,15 +56,6 @@ export class ApprovalSyncService
|
||||
async handleApprovalDelta(delta: IDelta): Promise<void> {
|
||||
await this.processDelta(delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для одобрений
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleApprovalFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение поддерживаемых версий контрактов и таблиц
|
||||
*/
|
||||
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Matrix: комната секретаря — создаётся вручную председателем/советом из desktop.
|
||||
* Всегда незашифрована (E2EE секретарь транскрибировать не может). Тип (public/private)
|
||||
* выбирается при создании, поэтому isPrivate здесь не фиксируем — задаёт сервис.
|
||||
*/
|
||||
|
||||
import type { MatrixChatRoomPreset } from './matrix-chat-room-preset.types';
|
||||
|
||||
/**
|
||||
* Те же права, что у комнаты проекта Capital: модераторы (50) почти по всем настройкам;
|
||||
* 100 только у Matrix-админа для критичных state.
|
||||
*/
|
||||
function buildPowerLevels(adminUserId: string): Record<string, unknown> {
|
||||
return {
|
||||
users_default: 0,
|
||||
invite: 50,
|
||||
kick: 50,
|
||||
ban: 50,
|
||||
redact: 50,
|
||||
state_default: 50,
|
||||
events_default: 0,
|
||||
users: {
|
||||
[adminUserId]: 100,
|
||||
},
|
||||
events: {
|
||||
'm.room.name': 50,
|
||||
'm.room.topic': 50,
|
||||
'm.room.avatar': 50,
|
||||
'm.room.pinned_events': 50,
|
||||
'm.room.canonical_alias': 50,
|
||||
'm.room.aliases': 50,
|
||||
'm.room.power_levels': 100,
|
||||
'm.room.history_visibility': 50,
|
||||
'm.room.encryption': 100,
|
||||
'm.room.tombstone': 100,
|
||||
'm.room.server_acl': 100,
|
||||
|
||||
'im.vector.modular.widgets': 50,
|
||||
'm.widget': 50,
|
||||
'org.matrix.msc2876.widgets': 50,
|
||||
'io.element.widgets.layout': 50,
|
||||
|
||||
'io.element.call': 50,
|
||||
'io.element.call.member': 0,
|
||||
'org.matrix.msc3401.call': 50,
|
||||
'org.matrix.msc3401.call.member': 0,
|
||||
'm.call': 50,
|
||||
'm.call.invite': 50,
|
||||
'm.call.answer': 0,
|
||||
'm.call.hangup': 0,
|
||||
'm.call.candidates': 0,
|
||||
'm.call.select_answer': 0,
|
||||
'm.call.reject': 0,
|
||||
'm.call.negotiate': 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Базовый пресет комнаты секретаря. `isPrivate` переопределяется сервисом по флагу публичности.
|
||||
* `encrypt` всегда false — иначе секретарь не сможет читать сообщения и транскрибировать.
|
||||
*/
|
||||
export const SECRETARY_ROOM_MATRIX: MatrixChatRoomPreset = {
|
||||
label: 'Комната секретаря',
|
||||
isPrivate: true,
|
||||
encrypt: false,
|
||||
roomType: undefined,
|
||||
initialState: [],
|
||||
buildPowerLevels,
|
||||
};
|
||||
-26
@@ -23,32 +23,6 @@ export class ChatcoopProjectCommunicationRoomDTO {
|
||||
displayLabel!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Тип непроектной комнаты: пайщики, совет или комната секретаря.
|
||||
*/
|
||||
export enum NonProjectRoomKindGql {
|
||||
MEMBERS = 'MEMBERS',
|
||||
COUNCIL = 'COUNCIL',
|
||||
SECRETARY = 'SECRETARY',
|
||||
}
|
||||
|
||||
registerEnumType(NonProjectRoomKindGql, {
|
||||
name: 'NonProjectRoomKind',
|
||||
description: 'Тип комнаты вне проекта: пайщики, совет, комната секретаря',
|
||||
});
|
||||
|
||||
@ObjectType('ChatcoopNonProjectCommunicationRoom')
|
||||
export class ChatcoopNonProjectCommunicationRoomDTO {
|
||||
@Field({ description: 'Идентификатор комнаты Matrix' })
|
||||
matrixRoomId!: string;
|
||||
|
||||
@Field({ description: 'Подпись для отображения комнаты' })
|
||||
displayLabel!: string;
|
||||
|
||||
@Field(() => NonProjectRoomKindGql, { description: 'Тип комнаты (пайщики / совет / секретарь)' })
|
||||
kind!: NonProjectRoomKindGql;
|
||||
}
|
||||
|
||||
@ObjectType('ChatcoopRoomMessageLine')
|
||||
export class ChatcoopRoomMessageLineDTO {
|
||||
@Field(() => Float, { description: 'origin_server_ts из Matrix (мс)' })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user