notifications for meet with details

This commit is contained in:
Alex Ant
2026-04-04 01:38:54 +05:00
parent 94ab81fa64
commit 6bc8d793f8
17 changed files with 124 additions and 13 deletions
+13
View File
@@ -4137,6 +4137,11 @@ input CreateAnnualGeneralMeetInput {
"""Имя аккаунта кооператива"""
coopname: String!
"""
Дополнительная информация о формате собрания (ссылка, как участвовать и т.д.)
"""
details: String
"""Имя аккаунта инициатора"""
initiator: String!
@@ -6668,6 +6673,9 @@ type MeetPreProcessing {
"""Имя аккаунта кооператива"""
coopname: String!
"""Дополнительная информация о формате собрания"""
details: String
"""Хеш собрания"""
hash: String!
@@ -9368,6 +9376,11 @@ input RestartAnnualGeneralMeetInput {
"""Имя аккаунта кооператива"""
coopname: String!
"""
Доп. информация для нового слота; передайте явно, если нужна в повестке и уведомлениях. Поле не передано — у нового собрания details пустой
"""
details: String
"""Хеш собрания, которое необходимо перезапустить"""
hash: String!
@@ -282,7 +282,6 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
const timezone = this.getTimezoneDisplay();
const meetUrl = this.getNotificationUrl(meet);
const detailsPart = await this.meetDetailsPayloadPart(meet.hash);
const payload: Workflows.MeetReminderEnd.IPayload = {
coopShortName,
meetId: meet.id,
@@ -291,7 +290,6 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
timeDescription,
timezone,
meetUrl,
...detailsPart,
};
// Отправляем уведомления каждому пользователю в цикле
@@ -19,6 +19,8 @@ export interface ICreateMeetWithAgendaInput {
close_at: string
username: string
type: 'regular' | 'extra'
/** Необязательно; до 10000 символов, как на бэкенде */
details?: string
agenda_points: {
title: string
context: string
@@ -94,6 +96,8 @@ export async function createMeetWithAgenda(data: ICreateMeetWithAgendaInput): Pr
// Подписываем документ
const signedDocument = await signDocument(generatedDocument, data.username)
const detailsTrimmed = data.details?.trim()
// Создаем собрание
const result = await createMeet({
coopname: data.coopname,
@@ -103,7 +107,8 @@ export async function createMeetWithAgenda(data: ICreateMeetWithAgendaInput): Pr
secretary: data.secretary,
open_at: data.open_at,
close_at: data.close_at,
proposal: signedDocument
proposal: signedDocument,
...(detailsTrimmed ? { details: detailsTrimmed } : {}),
})
return result
}
@@ -54,6 +54,7 @@ const handleCreate = async (formData: any) => {
username: sessionStore.username,
type: formData.type,
agenda_points: formData.agenda_points,
details: formData.details,
});
SuccessAlert('Собрание успешно создано');
@@ -72,6 +72,18 @@ q-dialog(
standout='bg-teal text-white'
)
q-input.q-mt-md(
v-model='formData.details',
label='Дополнительная информация для пайщиков (необязательно): ссылка на трансляцию, как участвовать',
type='textarea',
autogrow,
:maxlength='maxMeetDetailsLength',
counter,
dense,
standout='bg-teal text-white',
:rules='[validateMeetDetailsLength]'
)
.text-h6.q-mt-md Повестка собрания
q-card.q-mb-lg.q-pa-sm(
@@ -161,6 +173,10 @@ import { UserSearchSelector } from 'src/shared/ui';
import type { CreateMeetPreset } from './types';
import type { AgendaPoint } from 'src/shared/hooks/useAgendaPoints';
const maxMeetDetailsLength = 10000;
const validateMeetDetailsLength = (val: string | null | undefined): true | string =>
!val || val.length <= maxMeetDetailsLength || `Не более ${maxMeetDetailsLength} символов`;
// Определяем пропсы один раз
const props = defineProps<{
modelValue: boolean;
@@ -205,6 +221,7 @@ const buildDefaults = () => ({
close_at: getFutureDateForForm(18, 12, 0), // через 18 дней, 12:00
username: session.username,
type: props.isChairman ? 'regular' : 'extra', // Тип по умолчанию в зависимости от роли
details: '',
});
// Локальная копия пунктов повестки, чтобы пользователь мог добавлять новые
@@ -20,6 +20,8 @@ export interface IRestartMeetWithProposalInput {
username: string
new_open_at: string
new_close_at: string
/** Текст для meet_pre нового hash; пустая строка — очистить */
details?: string
agenda_points: {
title: string
context: string
@@ -98,7 +100,8 @@ export async function restartMeetWithProposal(data: IRestartMeetWithProposalInpu
hash: data.hash,
new_open_at: data.new_open_at,
new_close_at: data.new_close_at,
newproposal: signedDocument
newproposal: signedDocument,
details: data.details,
})
console.log('on result', result)
@@ -140,6 +143,7 @@ export const useRestartMeet = (
const handleRestartMeet = async (data: {
new_open_at: string;
new_close_at: string;
details: string;
agenda_points: {
title: string;
context: string;
@@ -165,6 +169,7 @@ export const useRestartMeet = (
username: sessionStore.username,
new_open_at: data.new_open_at,
new_close_at: data.new_close_at,
details: data.details,
agenda_points: data.agenda_points,
type: originalMeetType // Передаем оригинальный тип собрания
})
@@ -62,6 +62,18 @@ q-dialog(
standout='bg-teal text-white'
)
q-input.q-mb-md(
v-model='formData.details',
label='Дополнительная информация для пайщиков (для нового слота; необязательно)',
type='textarea',
autogrow,
:maxlength='maxMeetDetailsLength',
counter,
dense,
standout='bg-teal text-white',
:rules='[validateMeetDetailsLength]'
)
.text-subtitle1.q-mb-sm Пункты повестки для перезапуска:
q-card.q-pa-xs.q-my-sm.rounded-borders(
@@ -118,6 +130,11 @@ import { env } from 'src/shared/config/Environment';
import { AgendaNumberAvatar } from 'src/shared/ui/AgendaNumberAvatar';
import { UserSearchSelector } from 'src/shared/ui';
import { parseLinks } from 'src/shared/lib/utils';
const maxMeetDetailsLength = 10000;
const validateMeetDetailsLength = (val: string | null | undefined): true | string =>
!val || val.length <= maxMeetDetailsLength || `Не более ${maxMeetDetailsLength} символов`;
const props = defineProps<{
modelValue: boolean;
loading?: boolean;
@@ -141,12 +158,14 @@ const formData = reactive(
new_secretary: '',
new_open_at: getCurrentLocalDateForForm(0.17),
new_close_at: getCurrentLocalDateForForm(1),
details: '',
}
: {
new_presider: '',
new_secretary: '',
new_open_at: getFutureDateForForm(15, 6, 0),
new_close_at: getFutureDateForForm(18, 12, 0),
details: '',
},
);
@@ -162,6 +181,8 @@ watch(
formData.new_secretary = currentMeet.processing.meet.secretary;
}
formData.details = currentMeet?.pre?.details?.trim() ? currentMeet.pre.details : '';
if (env.NODE_ENV === 'development') {
formData.new_open_at = getCurrentLocalDateForForm(0.17);
formData.new_close_at = getCurrentLocalDateForForm(1);
@@ -188,6 +209,7 @@ const handleSubmit = () => {
new_secretary: formData.new_secretary,
new_open_at: convertLocalDateToUTC(formData.new_open_at),
new_close_at: convertLocalDateToUTC(formData.new_close_at),
details: formData.details.trim(),
agenda_points: meet.processing.questions.map((q) => ({
title: q.title,
context: q.context,
@@ -2,6 +2,9 @@
.page-main-card.q-pa-lg
.text-h6.q-mb-md.full-width.text-center Повестка
.info-item.q-mb-md(v-if='meetDetailsHtml')
.text-body2(v-html='meetDetailsHtml')
.info-item.q-mb-md(v-for='(item, index) in meetAgendaItems', :key='index')
.row.items-start
.col-auto.q-mr-md
@@ -42,6 +45,11 @@ const props = defineProps<{
const coopname = computed(() => props.coopname || '');
const meetHash = computed(() => props.meetHash || '');
const meetDetailsHtml = computed(() => {
const raw = props.meet?.pre?.details?.trim();
return raw ? parseLinks(raw) : '';
});
const meetAgendaItems = computed(() => {
if (!props.meet) return [];
return props.meet.processing?.questions || [];
@@ -26,8 +26,14 @@ export class Factory extends DocFactory<AnnualGeneralMeetingSovietDecision.Actio
const coop = await super.getCooperative(data.coopname, data.block_num)
const vars = await super.getVars(data.coopname, data.block_num)
// Извлекаем данные уже принятого решения совета
const decision = await super.getApprovedDecision(coop, data.coopname, data.decision_id)
// До авторизации решения — голоса в цепочке; после — запись решения удаляется, нужен authorize
let decision: AnnualGeneralMeetingSovietDecision.Model['decision']
try {
decision = await super.getDecision(coop, data.coopname, data.decision_id, meta.created_at)
}
catch {
decision = await super.getApprovedDecision(coop, data.coopname, data.decision_id)
}
// Извлекаем данные собрания из блокчейна по хэшу
const meet = await super.getMeet(data.coopname, data.meet_hash, data.block_num)
+1 -1
View File
@@ -260,7 +260,7 @@ export abstract class DocFactory<T extends IGenerate> {
})))?.results[0]?.data as SovietContract.Actions.Decisions.Authorize.IAuthorize
if (!decision) {
throw new Error(`Принятое решение не найдено в дельтах: decision_id=${decision_id}, coopname=${coopname}`)
throw new Error(`Принятое решение не найдено в действиях authorize: decision_id=${decision_id}, coopname=${coopname}`)
}
// Извлекаем документ авторизации из поля authorization
+10
View File
@@ -0,0 +1,10 @@
# @coopenomics/notifications — шаблоны Novu
## Надо
- Переменные: `{{payload.field}}` (как в существующих workflow).
- Условия и ветвление: **LiquidJS**`{% if payload.details %}…{% endif %}`, циклы/фильтры — синтаксис Liquid.
## Нельзя
- **Handlebars** в телах шагов: `{{#if …}}`, `{{/if}}`, `{{#each …}}` — Novu это не парсит как Handlebars; получишь ошибку вида *invalid value expression: "#if payload.details"*.
@@ -37,12 +37,12 @@ export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder
createEmailStep(
'meet-initial-email',
'Уведомление о общем собрании пайщиков №{{payload.meetId}} в {{payload.coopShortName}}',
'Уважаемый пайщик!<br><br>В кооперативе объявлено новое общее собрание №{{payload.meetId}}.<br><br>Дата и время начала: {{payload.meetDate}} в {{payload.meetTime}} ({{payload.timezone}})<br>Дата и время завершения: {{payload.meetEndDate}} в {{payload.meetEndTime}} ({{payload.timezone}})<br><br>Для ознакомления с повесткой, пожалуйста, перейдите по ссылке:<br><a href="{{payload.meetUrl}}">{{payload.meetUrl}}</a>{{#if payload.details}}<br><br>Дополнительная информация:<div style="white-space:pre-wrap;">{{payload.details}}</div>{{/if}}<br><br>С уважением, Совет {{payload.coopShortName}}.'
'Уважаемый пайщик!<br><br>В кооперативе объявлено новое общее собрание №{{payload.meetId}}.<br><br>Дата и время начала: {{payload.meetDate}} в {{payload.meetTime}} ({{payload.timezone}})<br>Дата и время завершения: {{payload.meetEndDate}} в {{payload.meetEndTime}} ({{payload.timezone}})<br><br>Для ознакомления с повесткой, пожалуйста, перейдите по ссылке:<br><a href="{{payload.meetUrl}}">{{payload.meetUrl}}</a>{% if payload.details %}<br><br>Дополнительная информация:<div style="white-space:pre-wrap;">{{payload.details}}</div>{% endif %}<br><br>С уважением, Совет {{payload.coopShortName}}.'
),
createInAppStep(
'meet-initial-notification',
'Новое общее собрание №{{payload.meetId}}',
'Назначено собрание на {{payload.meetDate}} в {{payload.meetTime}}{{#if payload.details}}. {{payload.details}}{{/if}}'
'Назначено собрание на {{payload.meetDate}} в {{payload.meetTime}}{% if payload.details %}. {{payload.details}}{% endif %}'
),
createPushStep(
'meet-initial-push',
@@ -35,12 +35,12 @@ export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder
createEmailStep(
'meet-reminder-start-email',
'Напоминание о предстоящем общем собрании №{{payload.meetId}} в {{payload.coopShortName}}',
'Уважаемый пайщик!<br><br>Напоминаем, что {{payload.timeDescription}} состоится общее собрание пайщиков №{{payload.meetId}} ({{payload.meetDate}} в {{payload.meetTime}}).<br><br>Для ознакомления с повесткой и подписи уведомления, пожалуйста, перейдите по ссылке:<br><a href="{{payload.meetUrl}}">{{payload.meetUrl}}</a>{{#if payload.details}}<br><br>Дополнительная информация:<div style="white-space:pre-wrap;">{{payload.details}}</div>{{/if}}<br><br>С уважением, Совет {{payload.coopShortName}}.'
'Уважаемый пайщик!<br><br>Напоминаем, что {{payload.timeDescription}} состоится общее собрание пайщиков №{{payload.meetId}} ({{payload.meetDate}} в {{payload.meetTime}}).<br><br>Для ознакомления с повесткой и подписи уведомления, пожалуйста, перейдите по ссылке:<br><a href="{{payload.meetUrl}}">{{payload.meetUrl}}</a>{% if payload.details %}<br><br>Дополнительная информация:<div style="white-space:pre-wrap;">{{payload.details}}</div>{% endif %}<br><br>С уважением, Совет {{payload.coopShortName}}.'
),
createInAppStep(
'meet-reminder-start-notification',
'Напоминание о собрании №{{payload.meetId}}',
'Собрание начнется {{payload.timeDescription}}{{#if payload.details}}. {{payload.details}}{{/if}}'
'Собрание начнется {{payload.timeDescription}}{% if payload.details %}. {{payload.details}}{% endif %}'
),
createPushStep(
'meet-reminder-start-push',
@@ -35,12 +35,12 @@ export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder
createEmailStep(
'meet-started-email',
'Собрание пайщиков №{{payload.meetId}} в {{payload.coopShortName}} началось',
'Уважаемый пайщик!<br><br>Сегодня началось общее собрание пайщиков №{{payload.meetId}}.<br>Собрание будет проходить до {{payload.meetEndDate}} {{payload.meetEndTime}} ({{payload.timezone}}).<br><br>Просим принять участие в голосовании по вопросам повестки дня.<br>Для голосования перейдите по ссылке:<br><a href="{{payload.meetUrl}}">{{payload.meetUrl}}</a>{{#if payload.details}}<br><br>Дополнительная информация:<div style="white-space:pre-wrap;">{{payload.details}}</div>{{/if}}<br><br>С уважением, Совет {{payload.coopShortName}}.'
'Уважаемый пайщик!<br><br>Сегодня началось общее собрание пайщиков №{{payload.meetId}}.<br>Собрание будет проходить до {{payload.meetEndDate}} {{payload.meetEndTime}} ({{payload.timezone}}).<br><br>Просим принять участие в голосовании по вопросам повестки дня.<br>Для голосования перейдите по ссылке:<br><a href="{{payload.meetUrl}}">{{payload.meetUrl}}</a>{% if payload.details %}<br><br>Дополнительная информация:<div style="white-space:pre-wrap;">{{payload.details}}</div>{% endif %}<br><br>С уважением, Совет {{payload.coopShortName}}.'
),
createInAppStep(
'meet-started-notification',
'Собрание №{{payload.meetId}} началось',
'Примите участие в голосовании{{#if payload.details}}. {{payload.details}}{{/if}}'
'Примите участие в голосовании{% if payload.details %}. {{payload.details}}{% endif %}'
),
createPushStep(
'meet-started-push',
@@ -24,6 +24,7 @@ const rawMeetPreProcessingSelector = {
open_at: true,
close_at: true,
proposal: rawDocumentAggregateSelector,
details: true,
}
// Проверка валидности MeetPreProcessing
+1
View File
@@ -3066,6 +3066,7 @@ export const ReturnTypes: Record<string,any> = {
agenda:"AgendaMeetPoint",
close_at:"DateTime",
coopname:"String",
details:"String",
hash:"String",
initiator:"String",
initiator_certificate:"UserCertificateUnion",
+24
View File
@@ -3830,6 +3830,8 @@ export type ValueTypes = {
close_at: ValueTypes["DateTime"] | Variable<any, string>,
/** Имя аккаунта кооператива */
coopname: string | Variable<any, string>,
/** Дополнительная информация о формате собрания (ссылка, как участвовать и т.д.) */
details?: string | undefined | null | Variable<any, string>,
/** Имя аккаунта инициатора */
initiator: string | Variable<any, string>,
/** Время открытия собрания */
@@ -5549,6 +5551,8 @@ export type ValueTypes = {
close_at?:boolean | `@${string}`,
/** Имя аккаунта кооператива */
coopname?:boolean | `@${string}`,
/** Дополнительная информация о формате собрания */
details?:boolean | `@${string}`,
/** Хеш собрания */
hash?:boolean | `@${string}`,
/** Инициатор собрания */
@@ -7153,6 +7157,8 @@ searchPrivateAccounts?: [{ data: ValueTypes["SearchPrivateAccountsInput"] | Vari
["RestartAnnualGeneralMeetInput"]: {
/** Имя аккаунта кооператива */
coopname: string | Variable<any, string>,
/** Доп. информация для нового слота; передайте явно, если нужна в повестке и уведомлениях. Поле не передано — у нового собрания details пустой */
details?: string | undefined | null | Variable<any, string>,
/** Хеш собрания, которое необходимо перезапустить */
hash: string | Variable<any, string>,
/** Новая дата закрытия собрания */
@@ -11237,6 +11243,8 @@ export type ResolverInputTypes = {
close_at: ResolverInputTypes["DateTime"],
/** Имя аккаунта кооператива */
coopname: string,
/** Дополнительная информация о формате собрания (ссылка, как участвовать и т.д.) */
details?: string | undefined | null,
/** Имя аккаунта инициатора */
initiator: string,
/** Время открытия собрания */
@@ -12956,6 +12964,8 @@ export type ResolverInputTypes = {
close_at?:boolean | `@${string}`,
/** Имя аккаунта кооператива */
coopname?:boolean | `@${string}`,
/** Дополнительная информация о формате собрания */
details?:boolean | `@${string}`,
/** Хеш собрания */
hash?:boolean | `@${string}`,
/** Инициатор собрания */
@@ -14562,6 +14572,8 @@ searchPrivateAccounts?: [{ data: ResolverInputTypes["SearchPrivateAccountsInput"
["RestartAnnualGeneralMeetInput"]: {
/** Имя аккаунта кооператива */
coopname: string,
/** Доп. информация для нового слота; передайте явно, если нужна в повестке и уведомлениях. Поле не передано — у нового собрания details пустой */
details?: string | undefined | null,
/** Хеш собрания, которое необходимо перезапустить */
hash: string,
/** Новая дата закрытия собрания */
@@ -18577,6 +18589,8 @@ export type ModelTypes = {
close_at: ModelTypes["DateTime"],
/** Имя аккаунта кооператива */
coopname: string,
/** Дополнительная информация о формате собрания (ссылка, как участвовать и т.д.) */
details?: string | undefined | null,
/** Имя аккаунта инициатора */
initiator: string,
/** Время открытия собрания */
@@ -20249,6 +20263,8 @@ export type ModelTypes = {
close_at: ModelTypes["DateTime"],
/** Имя аккаунта кооператива */
coopname: string,
/** Дополнительная информация о формате собрания */
details?: string | undefined | null,
/** Хеш собрания */
hash: string,
/** Инициатор собрания */
@@ -22010,6 +22026,8 @@ export type ModelTypes = {
["RestartAnnualGeneralMeetInput"]: {
/** Имя аккаунта кооператива */
coopname: string,
/** Доп. информация для нового слота; передайте явно, если нужна в повестке и уведомлениях. Поле не передано — у нового собрания details пустой */
details?: string | undefined | null,
/** Хеш собрания, которое необходимо перезапустить */
hash: string,
/** Новая дата закрытия собрания */
@@ -26070,6 +26088,8 @@ export type GraphQLTypes = {
close_at: GraphQLTypes["DateTime"],
/** Имя аккаунта кооператива */
coopname: string,
/** Дополнительная информация о формате собрания (ссылка, как участвовать и т.д.) */
details?: string | undefined | null,
/** Имя аккаунта инициатора */
initiator: string,
/** Время открытия собрания */
@@ -27790,6 +27810,8 @@ export type GraphQLTypes = {
close_at: GraphQLTypes["DateTime"],
/** Имя аккаунта кооператива */
coopname: string,
/** Дополнительная информация о формате собрания */
details?: string | undefined | null,
/** Хеш собрания */
hash: string,
/** Инициатор собрания */
@@ -29633,6 +29655,8 @@ export type GraphQLTypes = {
["RestartAnnualGeneralMeetInput"]: {
/** Имя аккаунта кооператива */
coopname: string,
/** Доп. информация для нового слота; передайте явно, если нужна в повестке и уведомлениях. Поле не передано — у нового собрания details пустой */
details?: string | undefined | null,
/** Хеш собрания, которое необходимо перезапустить */
hash: string,
/** Новая дата закрытия собрания */