merge dev into perf/factory-weasyprint-warm-pool (PR #99 publish + др.)
This commit is contained in:
@@ -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],
|
||||
|
||||
+6
-3
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+36
-4
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+472
-1414
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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+472
-1414
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user