fix(marketplace): гейт стола заказчика для председателя + подпись оферты долетает

Два бага L3-онбординга заказчика:

1) Председатель (и совет) видели рабочие страницы стола заказчика
   (Каталог/Мои заказы/Сводный) до подписи оферты: страницы требовали
   Offer:read / Order:read:own, а admin/board дают голый Offer:read и
   Order:read:all, который разворачивается в :own — гранты «протекали»
   на стол заказчика. Фикс: рабочие страницы требуют orderer-эксклюзивный
   Order:create (нет у admin/board, не покрывается :all-разворотом) —
   стол заказчика гейтится персональной подписью для любой роли.

2) Подпись оферты не долетала: getOnboardingState читал AgreementRepository
   (soviet::agreements3), а ЦПП «Стол заказов» — ПРОГРАММА (program_id=2),
   и wallet::signagree пишет подпись в wallet::users.programs[]. Источник
   истины — UserAgreementRepository.findProgram(program_id) (канон
   AgreementService.fetchProgrammatic). Из-за чтения не той таблицы
   requires_gate оставался true навсегда. Фикс: читаем программную подпись.

3) Авто-переключение после подписи: фронт коротко поллит getOnboardingState
   пока подпись синхронизируется в PG (wallet::signagree уже подтверждён
   цепочкой), затем loadDesktop+навигация — без ручного refresh.

Тесты: onboarding-service переписан под программный источник (6), grants (8) — 14/14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-29 06:21:12 +00:00
parent 71f629b084
commit 3919fe7fa8
4 changed files with 165 additions and 122 deletions
@@ -1,11 +1,15 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import type { TransactResult } from '@wharfkit/session';
import { AGREEMENT_REPOSITORY, AgreementRepository } from '~/domain/agreement/repositories/agreement.repository';
import {
USER_AGREEMENT_REPOSITORY,
UserAgreementRepository,
} from '~/domain/wallet/repositories/user-agreement.repository';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { SOVIET_BLOCKCHAIN_PORT, SovietBlockchainPort } from '~/domain/common/ports/soviet-blockchain.port';
import { WALLET_BLOCKCHAIN_PORT, WalletBlockchainPort } from '~/domain/wallet/ports/wallet-blockchain.port';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import { config } from '~/config';
import {
MARKETPLACE_AGREEMENT_TYPE,
@@ -18,24 +22,26 @@ import { MarketplaceOnboardingStateDTO } from '../dto/marketplace-onboarding-sta
* Story 1.4: L3 fallback gate marketplace.
*
* Контракт: расширение само не хранит «подписал/не подписал». Источник правды —
* `soviet::agreements3`, синхронизированная в `AgreementRepository` через
* `AgreementSyncService` (см. CLAUDE.md read-path: только PG-repository).
* блокчейн, синхронизированный в PG (read-path: только PG-repository).
*
* ВАЖНО — правильный источник: ЦПП «Стол заказов» это ПРОГРАММА (program_id=2),
* и подпись пайщика делается через `wallet::signagree` в `wallet::users.programs[]`,
* а НЕ в `soviet::agreements3`. Поэтому состояние читаем из
* `UserAgreementRepository` (синк `user-agreement-sync.service` из `wallet::users`),
* а не из `AgreementRepository` (синк `agreement-sync.service` из `agreements3`).
* Это тот же канон, что в `AgreementService.fetchProgrammatic`. Раньше здесь
* читался `AgreementRepository`, куда программная подпись НИКОГДА не попадает —
* из-за чего `requires_gate` оставался `true` навсегда после подписи.
*
* Локальная таблица `marketplace_onboarding_state` из PRD не заводится:
* - `AgreementRepository.findByUsername` уже даёт быстрый доступ к подписям
* (PG индекс по username);
* - различение source ('registration_flow' vs 'extension_gate') в текущей
* `agreements3` отсутствует — оба пути пишут одинаковую запись с
* `agreement_type = 'marketplace'`. Story 1.11 при добавлении L2 при
* необходимости заведёт локальный source-маркер.
*
* TTL 60s из PRD AC тоже опускаем — `AgreementRepository` сам синхронизирован
* с blockchain, локальный staleness ничем не отличается от core data.
* быстрый доступ уже даёт PG-кеш `wallet::users`. TTL 60s из PRD AC опускаем —
* репозиторий синхронизирован с blockchain, локальный staleness не отличается
* от core data.
*/
@Injectable()
export class MarketplaceOnboardingService {
constructor(
@Inject(AGREEMENT_REPOSITORY) private readonly agreementRepository: AgreementRepository,
@Inject(USER_AGREEMENT_REPOSITORY) private readonly userAgreementRepository: UserAgreementRepository,
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort,
@Inject(WALLET_BLOCKCHAIN_PORT) private readonly walletBlockchainPort: WalletBlockchainPort,
private readonly logger: WinstonLoggerService
@@ -55,23 +61,34 @@ export class MarketplaceOnboardingService {
});
}
const agreements = await this.agreementRepository.findByUsername(username);
const signed = agreements.find(
(a) =>
a.type === MARKETPLACE_AGREEMENT_TYPE &&
(a.draft_id === undefined ||
a.draft_id === null ||
Number(a.draft_id) === templateRegistryId)
// program_id ЦПП «Стол заказов» из `soviet::coagreements` (тот же лукап, что
// и при подписи). Без настроенной как программа ЦПП гейтить нечем — пропускаем.
const coopname = config.coopname;
const coagreement = await this.sovietBlockchainPort.getCoagreement(
coopname,
MARKETPLACE_AGREEMENT_TYPE
);
const programId = coagreement ? Number(coagreement.program_id) : 0;
if (!coagreement || programId <= 0) {
return new MarketplaceOnboardingStateDTO({
requires_gate: false,
source: 'not_configured',
template_registry_id: templateRegistryId,
});
}
if (signed) {
// Подпись программной оферты живёт в `wallet::users.programs[]`.
const owner = await this.userAgreementRepository.findByUsername(coopname, username);
const program =
owner && owner.present !== false ? owner.findProgram(programId) : undefined;
if (program) {
const source: MarketplaceOnboardingSource = 'agreement_signed';
return new MarketplaceOnboardingStateDTO({
requires_gate: false,
source,
template_registry_id: templateRegistryId,
completed_at: signed.updated_at ? String(signed.updated_at) : undefined,
agreement_id: signed.id ?? undefined,
completed_at: program.signed_at ? String(program.signed_at) : undefined,
});
}
@@ -1,31 +1,53 @@
/**
* Unit-тесты MarketplaceOnboardingService (Story 1.4).
* Unit-тесты MarketplaceOnboardingService.getOnboardingState (Story 1.4).
*
* Покрывают AC:
* (a) MARKETPLACE_OFFER_TEMPLATE_REGISTRY_ID = 0 (Story 1.7 не выполнена)
* → requires_gate=false, source='not_configured';
* (b) есть подписанная оферта marketplace в core `soviet::agreements3`
* → requires_gate=false, source='agreement_signed', completed_at/agreement_id
* заполнены;
* (c) подписи нет → requires_gate=true, source='gate_required'.
* ЦПП «Стол заказов» — ПРОГРАММА (program_id=2): подпись пайщика делается через
* `wallet::signagree` в `wallet::users.programs[]`, поэтому состояние читается
* из `UserAgreementRepository` (НЕ из `agreements3`/`AgreementRepository`).
*
* Для случая (b/c) подменяем константу через jest.doMock — она captured
* сервисом в момент вызова, не на import-time.
* Покрывают:
* (a) MARKETPLACE_OFFER_TEMPLATE_REGISTRY_ID = 0 → not_configured;
* (b) ЦПП не настроена как программа (нет коагримента / program_id<=0)
* → not_configured (гейтить нечем);
* (c) пайщик подписал программу → requires_gate=false, source=agreement_signed,
* completed_at из signed_at;
* (d) подписи нет (owner=null или нет нужной program_id) → requires_gate=true;
* (e) owner.present=false (откат форком) → подпись не засчитывается.
*/
const makeAgreement = (overrides: any = {}) => ({
id: 42,
type: 'marketplace',
draft_id: 7,
username: 'alice',
coopname: 'voskhod',
updated_at: '2026-05-14T12:00:00Z',
const PROGRAM_ID = 2;
const makeProgram = (overrides: any = {}) => ({
program_id: PROGRAM_ID,
doc_hash: 'abc',
version: 1,
draft_id: 1100,
signed_at: '2026-05-14T12:00:00Z',
...overrides,
});
const makeRepo = (agreements: any[]) =>
const makeOwner = (programs: any[], present = true) =>
({
findByUsername: jest.fn().mockResolvedValue(agreements),
present,
programs,
findProgram(pid: number | string) {
return programs.find((p) => Number(p.program_id) === Number(pid));
},
} as any);
const makeUserAgreementRepo = (owner: any) =>
({
findByUsername: jest.fn().mockResolvedValue(owner),
} as any);
const makeSovietPort = (coagreement: any) =>
({
getCoagreement: jest.fn().mockResolvedValue(coagreement),
} as any);
const makeWalletPort = () =>
({
signProgramAgreement: jest.fn(),
} as any);
const makeLogger = () =>
@@ -37,17 +59,7 @@ const makeLogger = () =>
debug: jest.fn(),
} as any);
// getOnboardingState не обращается к blockchain-портам (только agreementRepository),
// поэтому для этих кейсов достаточно заглушек методов, используемых в других путях.
const makeSovietPort = () =>
({
getCoagreement: jest.fn(),
} as any);
const makeWalletPort = () =>
({
signProgramAgreement: jest.fn(),
} as any);
const COAGREEMENT = { program_id: PROGRAM_ID, draft_id: 1100, type: 'marketplace' };
describe('MarketplaceOnboardingService.getOnboardingState', () => {
afterEach(() => {
@@ -65,96 +77,83 @@ describe('MarketplaceOnboardingService.getOnboardingState', () => {
const { MarketplaceOnboardingService } = await import(
'~/extensions/marketplace/application/onboarding/marketplace-onboarding.service'
);
const repo = makeRepo([]);
const service = new MarketplaceOnboardingService(repo, makeSovietPort(), makeWalletPort(), makeLogger());
const repo = makeUserAgreementRepo(null);
const soviet = makeSovietPort(COAGREEMENT);
const service = new MarketplaceOnboardingService(repo, soviet, makeWalletPort(), makeLogger());
const state = await service.getOnboardingState('alice');
expect(state.requires_gate).toBe(false);
expect(state.source).toBe('not_configured');
expect(state.template_registry_id).toBe(0);
expect(soviet.getCoagreement).not.toHaveBeenCalled();
expect(repo.findByUsername).not.toHaveBeenCalled();
});
it('Story 1.7 размещена (template_registry_id=1100 из cooptypes), подписи нет → requires_gate=true', async () => {
it('ЦПП не настроена как программа (нет коагримента) → not_configured', async () => {
jest.dontMock('~/extensions/marketplace/constants/marketplace-agreement-ids');
const { MarketplaceOnboardingService } = await import(
'~/extensions/marketplace/application/onboarding/marketplace-onboarding.service'
);
const repo = makeRepo([]);
const service = new MarketplaceOnboardingService(repo, makeSovietPort(), makeWalletPort(), makeLogger());
const repo = makeUserAgreementRepo(null);
const service = new MarketplaceOnboardingService(repo, makeSovietPort(null), makeWalletPort(), makeLogger());
const state = await service.getOnboardingState('alice');
expect(state.template_registry_id).toBe(1100);
expect(state.requires_gate).toBe(true);
expect(state.source).toBe('gate_required');
expect(state.requires_gate).toBe(false);
expect(state.source).toBe('not_configured');
expect(repo.findByUsername).not.toHaveBeenCalled();
});
it('подпись marketplace есть → requires_gate=false, source=agreement_signed, completed_at/agreement_id заполнены', async () => {
jest.doMock('~/extensions/marketplace/constants/marketplace-agreement-ids', () => ({
__esModule: true,
MARKETPLACE_EXTENSION_NAME: 'market',
MARKETPLACE_OFFER_AGREEMENT_ID: 'marketplace_offer',
MARKETPLACE_AGREEMENT_TYPE: 'marketplace',
MARKETPLACE_OFFER_TEMPLATE_REGISTRY_ID: 7,
}));
it('подпись программы есть → requires_gate=false, source=agreement_signed, completed_at из signed_at', async () => {
jest.dontMock('~/extensions/marketplace/constants/marketplace-agreement-ids');
const { MarketplaceOnboardingService } = await import(
'~/extensions/marketplace/application/onboarding/marketplace-onboarding.service'
);
const repo = makeRepo([makeAgreement({ draft_id: 7 })]);
const service = new MarketplaceOnboardingService(repo, makeSovietPort(), makeWalletPort(), makeLogger());
const owner = makeOwner([makeProgram()]);
const repo = makeUserAgreementRepo(owner);
const service = new MarketplaceOnboardingService(repo, makeSovietPort(COAGREEMENT), makeWalletPort(), makeLogger());
const state = await service.getOnboardingState('alice');
expect(state.requires_gate).toBe(false);
expect(state.source).toBe('agreement_signed');
expect(state.template_registry_id).toBe(7);
expect(state.agreement_id).toBe(42);
expect(state.completed_at).toBe('2026-05-14T12:00:00Z');
expect(repo.findByUsername).toHaveBeenCalledWith('alice');
});
it('подписи нет → requires_gate=true, source=gate_required', async () => {
jest.doMock('~/extensions/marketplace/constants/marketplace-agreement-ids', () => ({
__esModule: true,
MARKETPLACE_EXTENSION_NAME: 'market',
MARKETPLACE_OFFER_AGREEMENT_ID: 'marketplace_offer',
MARKETPLACE_AGREEMENT_TYPE: 'marketplace',
MARKETPLACE_OFFER_TEMPLATE_REGISTRY_ID: 7,
}));
it('подписи нет (owner=null) → requires_gate=true, source=gate_required', async () => {
jest.dontMock('~/extensions/marketplace/constants/marketplace-agreement-ids');
const { MarketplaceOnboardingService } = await import(
'~/extensions/marketplace/application/onboarding/marketplace-onboarding.service'
);
const repo = makeRepo([
makeAgreement({ type: 'capital', draft_id: 999 }), // не marketplace — игнор
makeAgreement({ type: 'marketplace', draft_id: 8 }), // другой шаблон — игнор
]);
const service = new MarketplaceOnboardingService(repo, makeSovietPort(), makeWalletPort(), makeLogger());
const repo = makeUserAgreementRepo(null);
const service = new MarketplaceOnboardingService(repo, makeSovietPort(COAGREEMENT), makeWalletPort(), makeLogger());
const state = await service.getOnboardingState('alice');
expect(state.requires_gate).toBe(true);
expect(state.source).toBe('gate_required');
expect(state.template_registry_id).toBe(7);
expect(state.agreement_id).toBeUndefined();
});
it('запись marketplace без draft_id (если контракт не проставил) считается совпадающей по type', async () => {
jest.doMock('~/extensions/marketplace/constants/marketplace-agreement-ids', () => ({
__esModule: true,
MARKETPLACE_EXTENSION_NAME: 'market',
MARKETPLACE_OFFER_AGREEMENT_ID: 'marketplace_offer',
MARKETPLACE_AGREEMENT_TYPE: 'marketplace',
MARKETPLACE_OFFER_TEMPLATE_REGISTRY_ID: 7,
}));
it('owner есть, но другой program_id → requires_gate=true', async () => {
jest.dontMock('~/extensions/marketplace/constants/marketplace-agreement-ids');
const { MarketplaceOnboardingService } = await import(
'~/extensions/marketplace/application/onboarding/marketplace-onboarding.service'
);
const repo = makeRepo([makeAgreement({ type: 'marketplace', draft_id: undefined, id: 99 })]);
const service = new MarketplaceOnboardingService(repo, makeSovietPort(), makeWalletPort(), makeLogger());
const owner = makeOwner([makeProgram({ program_id: 1 })]); // capital, не marketplace
const repo = makeUserAgreementRepo(owner);
const service = new MarketplaceOnboardingService(repo, makeSovietPort(COAGREEMENT), makeWalletPort(), makeLogger());
const state = await service.getOnboardingState('alice');
expect(state.requires_gate).toBe(false);
expect(state.agreement_id).toBe(99);
expect(state.requires_gate).toBe(true);
});
it('owner.present=false (откат форком) → подпись не засчитывается, requires_gate=true', async () => {
jest.dontMock('~/extensions/marketplace/constants/marketplace-agreement-ids');
const { MarketplaceOnboardingService } = await import(
'~/extensions/marketplace/application/onboarding/marketplace-onboarding.service'
);
const owner = makeOwner([makeProgram()], false);
const repo = makeUserAgreementRepo(owner);
const service = new MarketplaceOnboardingService(repo, makeSovietPort(COAGREEMENT), makeWalletPort(), makeLogger());
const state = await service.getOnboardingState('alice');
expect(state.requires_gate).toBe(true);
});
});
@@ -115,7 +115,15 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Каталог',
icon: 'fa-solid fa-store',
requires: 'Offer:read',
// Рабочие страницы стола заказчика гейтятся подпиской оферты
// ЦПП: требуем orderer-эксклюзивный грант `Order:create`. Его
// НЕ выдают роли admin/board, и `Order:read:all` в него НЕ
// разворачивается (нет `:all`-формы) — поэтому admin-гранты
// председателя (или совета) не «протекают» на стол заказчика
// до его персональной подписи. Реальный enforcement резолверов
// использует узкие права (`Offer:read`/`Order:read:own`) как
// прежде; здесь — только видимость стола.
requires: 'Order:create',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -128,7 +136,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Мои заказы',
icon: 'fa-solid fa-cart-shopping',
requires: 'Order:read:own',
requires: 'Order:create',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -145,7 +153,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Сводный заказ',
icon: 'fa-solid fa-layer-group',
requires: 'Order:read:own',
requires: 'Order:create',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -161,7 +169,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Готово к получению',
icon: 'fa-solid fa-box-open',
requires: 'Issuance:read:own',
requires: 'Order:create',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -177,7 +185,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
meta: {
title: 'Гарантийные возвраты',
icon: 'fa-solid fa-rotate-left',
requires: 'ReturnClaim:read:own',
requires: 'Order:create',
requiresAuth: true,
agreements: agreementsBase,
},
@@ -68,27 +68,46 @@ async function load(): Promise<void> {
}
}
/**
* После подписи `wallet::signagree` уже подтверждён цепочкой (мутация
* вернулась), но `requires_gate` и гранты завязаны на PG-кеш `wallet::users`,
* который parser синхронизирует со следующего блока. Коротко поллим состояние
* онбординга, пока подпись не отразится в PG, — тогда и `getDesktop` отдаст
* полные orderer-права. Так переключение происходит само, без ручного refresh
* (и без поллинга на каждом переходе — только здесь, разово после подписи).
*/
async function waitForSignatureSynced(): Promise<boolean> {
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 1500));
state.value = await fetchOnboardingState();
if (state.value && !state.value.requires_gate) return true;
}
return false;
}
async function onAccept(_documentIds: string[]): Promise<void> {
// L3 подпись прямо со стола: фоллоуап Эпика 1 (mutation
// `marketplaceSignOnboardingOffer`). Frontend рендерит оферту 1101 через
// documentFactory, подписывает локальным WIF, отправляет на backend, а тот
// вызывает on-chain `wallet::signagree` от лица coopname.
// После успешной подписи перезагружаем состояние онбординга — если
// requires_gate=false, ведём пайщика на каталог.
// L3 подпись прямо со стола: mutation `marketplaceSignOnboardingOffer`.
// Frontend рендерит оферту 1101 через documentFactory, подписывает локальным
// WIF, отправляет на backend, а тот вызывает on-chain `wallet::signagree` от
// лица coopname (программная подпись в wallet::users.programs[], program_id=2).
loading.value = true;
try {
state.value = await signOnboardingOffer();
if (state.value && !state.value.requires_gate) {
// requires_gate=false сразу — редкий случай (PG уже синхронен); иначе ждём
// синк подписи в PG коротким поллингом.
const confirmed =
(!!state.value && !state.value.requires_gate) || (await waitForSignatureSynced());
if (confirmed) {
Notify.create({
type: 'positive',
message: 'Оферта ЦПП «Стол заказов» подписана. Открываем стол заказчика…',
timeout: 1500,
});
// Подпись сменила L3-гейт: backend теперь выдаёт полные orderer-права
// Подпись синхронизирована: backend теперь выдаёт полные orderer-права
// вместо маркера Onboarding:orderer. Перечитываем десктоп (гранты) и
// переустанавливаем маршруты, затем ведём на первую доступную страницу
// стола (Каталог) — тот же канон refresh, что у EnableButton, без
// поллинга и без loadDesktop на каждом переходе.
// стола (Каталог) — тот же канон refresh, что у EnableButton.
await desktop.loadDesktop();
await loadExtensionRoutes('market', router);
const coopname = system.info?.coopname;
@@ -101,11 +120,11 @@ async function onAccept(_documentIds: string[]): Promise<void> {
: { name: 'marketplace-catalog' },
);
} else {
// Sync ещё не подтянул запись из chain — даём UI шанс перезапросить
// вручную через перезагрузку страницы.
// Синк не успел за отведённое окно — крайне редко; даём пользователю
// явный сигнал перезагрузить страницу.
Notify.create({
type: 'info',
message: 'Подпись отправлена. Подтверждение из блокчейна ожидается — обновите страницу через несколько секунд.',
message: 'Подпись принята блокчейном и синхронизируется. Обновите страницу через несколько секунд.',
});
}
} catch (e) {