merge dev into perf/factory-weasyprint-warm-pool (PR #99 publish + др.)
Typecheck / controller (pull_request) Has been cancelled
Typecheck / desktop (pull_request) Has been cancelled

This commit is contained in:
coopops
2026-06-05 14:29:48 +00:00
14 changed files with 1113 additions and 2849 deletions
@@ -18,6 +18,6 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
],
controllers: [],
providers: [AgendaInteractor, AgendaResolver, AgendaService],
exports: [],
exports: [AgendaService],
})
export class AgendaModule {}
@@ -80,4 +80,47 @@ export class AgendaInteractor {
}
return agenda;
}
/**
* Собирает ОДИН пункт повестки по хэшу документа-заявления. Нужен сразу после
* публикации свободного решения: возвращаем только что созданный вопрос фронту
* немедленно, без ожидания общего поллинга. Возвращает null, пока парсер ещё не
* проиндексировал действие newsubmitted или документ-заявление — тогда
* вызывающая сторона повторит попытку через паузу.
*/
async getAgendaItemByHash(coopname: string, hash: string): Promise<AgendaWithDocumentsDomainInterface | null> {
const target = String(hash).toUpperCase();
// decision появляется на чейне почти мгновенно после публикации.
const decisions = (await this.blockchainPort.getAllRows(
SovietContract.contractName.production,
coopname,
'decisions'
)) as SovietContract.Tables.Decisions.IDecision[];
const decision = decisions.find((d) => String(d.hash).toUpperCase() === target);
if (!decision) return null;
// action newsubmitted индексируется парсером с лагом (~2 c) — пока его нет,
// вернём null, и вызывающая сторона повторит тик.
const actionResponse = await getActions(`${process.env.SIMPLE_EXPLORER_API}/get-actions`, {
filter: JSON.stringify({
account: SovietContract.contractName.production,
name: SovietContract.Actions.Registry.NewSubmitted.actionName,
receiver: process.env.COOPNAME,
'data.package': target,
}),
page: 1,
limit: 1,
});
const action = actionResponse?.results?.[0];
if (!action) return null;
const documents = await this.documentPackageAggregator.buildDocumentPackageAggregate(action);
// Тот же фильтр, что в getAgenda: без агрегата заявления пункт не отображается.
if (!documents.statement?.documentAggregate) return null;
return { table: decision, action, documents };
}
}
@@ -58,4 +58,22 @@ export class AgendaService {
return processedAgenda;
}
/**
* Возвращает ОДИН пункт повестки по хэшу документа-заявления (или null, если он
* ещё не проиндексирован парсером). Используется сразу после публикации
* свободного решения, чтобы фронт показал созданный вопрос немедленно.
*/
public async getAgendaItemByHash(hash: string): Promise<AgendaWithDocumentsDTO | null> {
const item = await this.agendaInteractor.getAgendaItemByHash(config.coopname, hash);
if (!item) return null;
const boards = await this.sovietBlockchainPort.getBoards(config.coopname);
const councilMembersCount = boards.find((b) => b.type === 'soviet')?.members.length ?? 0;
const usernameCertificate = await this.userCertificateDomainPort.getCertificateByUsername(item.table.username);
// Только что созданный вопрос ещё без голосов — сертификаты голосовавших пусты.
return new AgendaWithDocumentsDTO(item, usernameCertificate, [], [], councilMembersCount);
}
}
@@ -7,6 +7,7 @@ import { DocumentDomainModule } from '~/domain/document/document.module';
import { GeneratorInfrastructureModule } from '~/infrastructure/generator/generator.module';
import { UserDomainModule } from '~/domain/user/user-domain.module';
import { FreeDecisionInteractor } from './interactors/free-decision.interactor';
import { AgendaModule } from '../agenda/agenda.module';
@Module({
imports: [
@@ -15,6 +16,7 @@ import { FreeDecisionInteractor } from './interactors/free-decision.interactor';
DocumentDomainModule,
GeneratorInfrastructureModule,
forwardRef(() => UserDomainModule),
AgendaModule,
],
controllers: [],
providers: [FreeDecisionResolver, FreeDecisionService, FreeDecisionInteractor],
@@ -12,6 +12,7 @@ import { CreateProjectFreeDecisionInputDTO } from '../dto/create-project-free-de
import { FreeDecisionGenerateDocumentInputDTO } from '../../document/documents-dto/free-decision-document.dto';
import { FreeDecisionService } from '../services/free-decision.service';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { AgendaWithDocumentsDTO } from '~/application/agenda/dto/agenda-with-documents.dto';
@Resolver()
export class FreeDecisionResolver {
@@ -49,16 +50,18 @@ export class FreeDecisionResolver {
return this.freeDecisionService.generateFreeDecision(data, options);
}
@Mutation(() => Boolean, {
@Mutation(() => AgendaWithDocumentsDTO, {
name: 'publishProjectOfFreeDecision',
description: 'Опубликовать предложенную повестку и проект решения для дальнейшего голосования совета по нему',
nullable: true,
description:
'Опубликовать предложенную повестку и проект решения для голосования совета. Возвращает созданный пункт повестки (или null, если он ещё не проиндексирован) для немедленного отображения на фронте.',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async publishProjectOfFreeDecision(
@Args('data', { type: () => PublishProjectFreeDecisionInputDTO })
data: PublishProjectFreeDecisionInputDTO
): Promise<boolean> {
): Promise<AgendaWithDocumentsDTO | null> {
return this.freeDecisionService.publishProjectOfFreeDecision(data);
}
@@ -8,10 +8,25 @@ import type { FreeDecisionGenerateDocumentInputDTO } from '../../document/docume
import { FreeDecisionInteractor } from '~/application/free-decision/interactors/free-decision.interactor';
import type { ProjectFreeDecisionGenerateDocumentInputDTO } from '~/application/document/documents-dto/project-free-decision-document.dto';
import type { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { AgendaService } from '~/application/agenda/services/agenda.service';
import type { AgendaWithDocumentsDTO } from '~/application/agenda/dto/agenda-with-documents.dto';
// Повестка собирается join'ом таблицы decisions из блокчейна (доступна сразу)
// с проиндексированными парсером действием newsubmitted и документом-заявлением.
// Решение появляется на чейне мгновенно, но индексация парсером action'а занимает
// ~2 c — поэтому опрашиваем повестку короткими тиками, пока вопрос не соберётся,
// и возвращаем его фронту немедленно (без ожидания общего поллинга страницы).
const PUBLISH_FETCH_DELAY_MS = 400;
const PUBLISH_FETCH_ATTEMPTS = 13; // ~5 c суммарно — запас над типичными ~2 c
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
@Injectable()
export class FreeDecisionService {
constructor(private readonly freeDecisionInteractor: FreeDecisionInteractor) {}
constructor(
private readonly freeDecisionInteractor: FreeDecisionInteractor,
private readonly agendaService: AgendaService
) {}
public async generateProjectOfFreeDecision(
data: ProjectFreeDecisionGenerateDocumentInputDTO,
@@ -31,9 +46,26 @@ export class FreeDecisionService {
return document as unknown as GeneratedDocumentDTO;
}
public async publishProjectOfFreeDecision(data: PublishProjectFreeDecisionInputDTO): Promise<boolean> {
const selected = await this.freeDecisionInteractor.publishProjectOfFreeDecision(data);
return selected;
public async publishProjectOfFreeDecision(
data: PublishProjectFreeDecisionInputDTO
): Promise<AgendaWithDocumentsDTO | null> {
await this.freeDecisionInteractor.publishProjectOfFreeDecision(data);
// decision.hash в блокчейне == «общий хэш» подписанного заявления (hash =
// doc_hash + meta_hash), НЕ doc_hash (хэш только содержимого) — их легко
// перепутать, и матч по doc_hash молча никогда не сработает.
const hash = data.document.hash;
let item: AgendaWithDocumentsDTO | null = null;
for (let attempt = 0; attempt < PUBLISH_FETCH_ATTEMPTS; attempt++) {
item = await this.agendaService.getAgendaItemByHash(hash);
if (item) break;
await sleep(PUBLISH_FETCH_DELAY_MS);
}
// null допустим: если парсер не успел проиндексировать — фронт покажет вопрос
// на ближайшем тике поллинга (деградация, не ошибка).
return item;
}
public async createProjectOfFreeDecision(data: CreateProjectFreeDecisionInputDTO): Promise<CreatedProjectFreeDecisionDTO> {
+1 -1
View File
@@ -3668,7 +3668,7 @@ export const ReturnTypes: Record<string,any> = {
notifyOnAnnualGeneralMeet:"MeetAggregate",
processConvertToAxonStatement:"Boolean",
prohibitRequest:"Transaction",
publishProjectOfFreeDecision:"Boolean",
publishProjectOfFreeDecision:"AgendaWithDocuments",
publishRequest:"Transaction",
receiveOnRequest:"Transaction",
refresh:"RegisteredAccount",
File diff suppressed because it is too large Load Diff
@@ -1,25 +1,48 @@
import { defineStore } from 'pinia'
import { ref, Ref } from 'vue'
import { computed, ref, type ComputedRef, type Ref } from 'vue'
import { api } from '../api'
import type { IAgenda, IGetAgendaInput } from './types';
const namespace = 'agendaStore';
interface IAgendaStore {
agenda: Ref<IAgenda[]>
agenda: ComputedRef<IAgenda[]>
loading: Ref<boolean>
loadAgenda: (data: IGetAgendaInput, hidden?: boolean) => Promise<IAgenda[]>;
insertCreated: (item: IAgenda) => void;
}
export const useAgendaStore = defineStore(namespace, (): IAgendaStore => {
const agenda = ref<IAgenda[]>([])
// Authoritative-список из getAgenda (поллинг).
const loadedAgenda = ref<IAgenda[]>([])
const loading = ref(false)
// Оптимистично вставленные вопросы, которых ещё нет в loadedAgenda: бэкенд
// вернул созданный вопрос сразу после публикации (с settle-паузой), но
// getAgenda-поллинг его ещё не отдаёт — лаг индексации блокчейна парсером.
// Держим в буфере до подтверждения, чтобы вопрос не «мигал» (появился из
// publish → исчез на ближайшем поллинге → снова появился).
const createdBuffer = ref<IAgenda[]>([])
const idOf = (item: IAgenda): string => String(item?.table?.id ?? '')
// Итоговая повестка: ещё не подтверждённые из буфера (сверху) + authoritative.
const agenda = computed<IAgenda[]>(() => {
if (!createdBuffer.value.length) return loadedAgenda.value
const loadedIds = new Set(loadedAgenda.value.map(idOf))
const pending = createdBuffer.value.filter((item) => !loadedIds.has(idOf(item)))
return [...pending, ...loadedAgenda.value]
})
const loadAgenda = async (data: IGetAgendaInput, hidden = false): Promise<IAgenda[]> => {
try {
loading.value = hidden ? false : true
const loadedData = await api.loadAgenda(data);
agenda.value = loadedData;
loadedAgenda.value = loadedData;
// Подтверждённые вопросы убираем из буфера: authoritative-данные пришли.
if (createdBuffer.value.length) {
const loadedIds = new Set(loadedData.map(idOf))
createdBuffer.value = createdBuffer.value.filter((item) => !loadedIds.has(idOf(item)))
}
loading.value = false
return loadedData;
} catch (error) {
@@ -28,9 +51,19 @@ export const useAgendaStore = defineStore(namespace, (): IAgendaStore => {
}
};
// Оптимистично добавляет только что созданный вопрос (из ответа publish).
const insertCreated = (item: IAgenda): void => {
if (!item?.table?.id) return
const id = idOf(item)
if (createdBuffer.value.some((existing) => idOf(existing) === id)) return
if (loadedAgenda.value.some((existing) => idOf(existing) === id)) return
createdBuffer.value = [item, ...createdBuffer.value]
}
return {
agenda,
loading,
loadAgenda
loadAgenda,
insertCreated
}
})
})
@@ -1,5 +1,6 @@
import { Mutations } from '@coopenomics/sdk'
import type { ICreatedProjectDecisionData, IGeneratedProjectDecisionDocument } from 'src/entities/Decision/model';
import type { IAgenda } from 'src/entities/Agenda/model';
import { client } from 'src/shared/api/client';
import { useSignDocument } from 'src/shared/lib/document';
import { ref, type Ref } from 'vue';
@@ -14,7 +15,9 @@ export function useCreateProjectOfFreeDecision() {
question: ''
});
async function createProject(coopname: string, username: string){
// Возвращает созданный пункт повестки (или null, если бэкенд не успел его
// извлечь из блокчейна) — чтобы кнопка вставила его в таблицу немедленно.
async function createProject(coopname: string, username: string): Promise<IAgenda | null> {
const createdProject = await createProjectOfFreeDecision()
const title = createProjectInput.value.title?.trim();
@@ -31,7 +34,7 @@ export function useCreateProjectOfFreeDecision() {
const signedDocument = await signDocument(generatedDocument, username)
await publishProjectOfFreeDecision({
return await publishProjectOfFreeDecision({
coopname: coopname,
document: signedDocument,
meta: normalizedTitle ? JSON.stringify({ title: normalizedTitle }) : '',
@@ -66,7 +69,9 @@ export function useCreateProjectOfFreeDecision() {
}
async function publishProjectOfFreeDecision(data: Mutations.FreeDecisions.PublishProjectOfFreeDecision.IInput['data']): Promise<Mutations.FreeDecisions.PublishProjectOfFreeDecision.IOutput[typeof Mutations.FreeDecisions.PublishProjectOfFreeDecision.name]> {
// Публикует решение и возвращает созданный пункт повестки (бэкенд извлекает его
// из блокчейна с settle-паузой) либо null, если он ещё не проиндексирован.
async function publishProjectOfFreeDecision(data: Mutations.FreeDecisions.PublishProjectOfFreeDecision.IInput['data']): Promise<IAgenda | null> {
const { [Mutations.FreeDecisions.PublishProjectOfFreeDecision.name]: result } = await client.Mutation(
Mutations.FreeDecisions.PublishProjectOfFreeDecision.mutation,
{
@@ -76,6 +76,7 @@ import {
import { notEmpty } from 'src/shared/lib/utils';
import { useSessionStore } from 'src/entities/Session';
import { useSystemStore } from 'src/entities/System/model';
import { useAgendaStore } from 'src/entities/Agenda/model';
import { useWindowSize } from 'src/shared/hooks';
const { isMobile } = useWindowSize();
@@ -85,17 +86,22 @@ const isLoading = ref(false);
const { createProjectInput, createProject } = useCreateProjectOfFreeDecision();
const session = useSessionStore();
const system = useSystemStore();
const agendaStore = useAgendaStore();
const create = async () => {
try {
isSubmitting.value = true;
await createProject(system.info.coopname, session.username);
const createdItem = await createProject(system.info.coopname, session.username);
isSubmitting.value = false;
show.value = false;
SuccessAlert('Вопрос добавлен на повестку для голосования');
createProjectInput.value.title = '';
createProjectInput.value.question = '';
createProjectInput.value.decision = '';
// Бэкенд вернул созданный вопрос (извлёк из блокчейна) — показываем его в
// таблице немедленно; голос/правка по нему заблокированы до подтверждения
// ближайшим getAgenda-поллингом.
if (createdItem) agendaStore.insertCreated(createdItem);
} catch (e) {
isSubmitting.value = false;
FailAlert(`Ошибка: ${extractGraphQLErrorMessages(e)}`);
@@ -1,9 +1,15 @@
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
import { rawAgendaSelector } from '../../selectors/agenda/agendaSelector'
export const name = 'publishProjectOfFreeDecision'
/**
* Публикует предложенную повестку и проект решения. Возвращает созданный пункт
* повестки (или null, если он ещё не проиндексирован) — для немедленного
* отображения на фронте без ожидания поллинга.
*/
export const mutation = Selector('Mutation')({
[name]: [{ data: $('data', 'PublishProjectFreeDecisionInput!') }, true],
[name]: [{ data: $('data', 'PublishProjectFreeDecisionInput!') }, rawAgendaSelector],
})
export interface IInput {
+1 -1
View File
@@ -3668,7 +3668,7 @@ export const ReturnTypes: Record<string,any> = {
notifyOnAnnualGeneralMeet:"MeetAggregate",
processConvertToAxonStatement:"Boolean",
prohibitRequest:"Transaction",
publishProjectOfFreeDecision:"Boolean",
publishProjectOfFreeDecision:"AgendaWithDocuments",
publishRequest:"Transaction",
receiveOnRequest:"Transaction",
refresh:"RegisteredAccount",
File diff suppressed because it is too large Load Diff