fix(controller/install): bundle adduser+createBoard в одну tx

Cause. install.interactor.ts шлёт adduser×N и createBoard двумя отдельными
tx через BlockchainService. На production-нодах с producer-схемой это
работает (lag реплицирования минимальный), но на dev-loop'е partner-
coopback соединён со своим nodeos, который p2p-репликой подтягивает
блоки от producer'а — после accept'а adduser в local state ещё нет
soviet::participants[username] к моменту push'а createBoard. Контракт
soviet::createboard падает «Один из аккаунтов не найден в реестре
пайщиков».

Fix. Объединил все adduser-action'ы и createBoard-action в одну tx
через новый метод BlockchainPort.installSoviet(). Обе action'ы теперь
атомарны в одном блоке — soviet::addpartcpnt (inline action от
adduser) обновляет participants и createBoard видит запись сразу же
в том же блоке.

Поток в install.interactor.ts перестроен в два шага:
  1. Цикл по soviet: createUser в БД + setupNotificationSubscriber +
     сбор addUserActions[] и members[] (без on-chain активности).
  2. installSoviet(addUserActions, createBoardData) — одна tx.

Catch при ошибке on-chain (как и раньше) откатывает users из БД.

Зачем. Закрывает блокер сценария 08-chairman-install-on-partner-dev:
без этого финальный экран wizard'а «Установка завершена» недостижим
на dev-loop'е (Эпик 0 не закрывается).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-05-18 15:29:20 +00:00
parent 5ed194bedd
commit 3f2ecc42cf
3 changed files with 47 additions and 9 deletions
@@ -136,14 +136,16 @@ export class InstallInteractor {
const users = [] as UserDomainEntity[];
const members = [] as any;
const sovietExt = [] as any;
const addUserActions: RegistratorContract.Actions.AddUser.IAddUser[] = [];
const soviet = data.soviet;
try {
// Шаг 1: создаём users в БД и собираем on-chain action'ы (без отправки)
for (const member of soviet) {
const username = generateUsername();
sovietExt.push({ ...member, username });
const addUser: RegistratorContract.Actions.AddUser.IAddUser = {
addUserActions.push({
coopname: config.coopname,
referer: '',
username,
@@ -154,9 +156,7 @@ export class InstallInteractor {
spread_initial: false,
meta: '',
registration_hash: sha256(username),
};
await this.blockchainPort.addUser(addUser);
});
const createUser: CreateUserInputDomainInterface = {
email: member.individual_data.email,
@@ -168,20 +168,17 @@ export class InstallInteractor {
};
const user = await this.createUser(createUser);
// Обновляем статус пользователя на "зарегистрирован"
await this.userDomainService.updateUserByUsername(username, {
status: userStatus['4_Registered'],
is_registered: true,
});
// Настраиваем подписчика уведомлений для члена совета
try {
await this.accountDomainService.setupNotificationSubscriber(username, 'члена совета');
} catch (error: any) {
logger.error(`Ошибка настройки подписчика NOVU для члена совета ${username}: ${error.message}`, error.stack);
}
// Добавляем в массив членов для отправки в блокчейн
members.push({
username: username,
is_voting: true,
@@ -192,8 +189,9 @@ export class InstallInteractor {
users.push(user);
}
// Создаём доску совета
await this.blockchainPort.createBoard({
// Шаг 2: bundle adduser×N + createBoard ОДНОЙ tx (исключает race
// между отдельными tx когда coopback соединён с не-producer nodeos)
await this.blockchainPort.installSoviet(addUserActions, {
coopname: config.coopname,
username: config.coopname,
type: 'soviet',
@@ -42,6 +42,10 @@ export interface BlockchainPort {
// System installation methods
addUser(data: RegistratorContract.Actions.AddUser.IAddUser): Promise<void>;
createBoard(data: SovietContract.Actions.Boards.CreateBoard.ICreateboard): Promise<void>;
installSoviet(
addUsers: RegistratorContract.Actions.AddUser.IAddUser[],
createBoardData: SovietContract.Actions.Boards.CreateBoard.ICreateboard
): Promise<void>;
}
export const BLOCKCHAIN_PORT = Symbol('BlockchainPort');
@@ -319,4 +319,40 @@ export class BlockchainService implements BlockchainPort {
await this.transact(actions);
}
// Шлёт adduser×N + createBoard ОДНОЙ транзакцией. Раздельные tx
// ловят race на не-producer-нодах (partner-coopback соединён со своим
// nodeos, который p2p-репликой подтягивает блоки от producer'а — между
// accept'ом adduser и push'ем createBoard нет гарантии что
// soviet::participants[username] уже виден в local state). Bundle
// делает обе action'ы атомарными в одном блоке.
public async installSoviet(
addUsers: RegistratorContract.Actions.AddUser.IAddUser[],
createBoardData: SovietContract.Actions.Boards.CreateBoard.ICreateboard
): Promise<void> {
const wif = await this.vaultDomainService.getWif(config.coopname);
if (!wif) throw new Error(`Не найден приватный ключ для кооператива ${config.coopname}`);
this.initialize(config.coopname, wif);
const actions: any[] = [];
for (const data of addUsers) {
actions.push({
account: RegistratorContract.contractName.production,
name: RegistratorContract.Actions.AddUser.actionName,
authorization: [{ actor: config.coopname, permission: 'active' }],
data,
});
}
actions.push({
account: SovietContract.contractName.production,
name: SovietContract.Actions.Boards.CreateBoard.actionName,
authorization: [{ actor: config.coopname, permission: 'active' }],
data: createBoardData,
});
await this.transact(actions);
}
}