calendar of events

This commit is contained in:
Alex Ant
2026-03-29 17:50:44 +05:00
parent 48ecb49a59
commit 646b1c90a2
20 changed files with 47 additions and 34 deletions
+1
View File
@@ -58,6 +58,7 @@ pnpm run reboot
- Controller/Parser (в Docker): хосты по именам контейнеров из docker-compose (порт БД 5432) - Controller/Parser (в Docker): хосты по именам контейнеров из docker-compose (порт БД 5432)
- Boot (на хосте): `127.0.0.1`, PG порт `5532`, mongo через `/etc/hosts` - Boot (на хосте): `127.0.0.1`, PG порт `5532`, mongo через `/etc/hosts`
- Desktop: `127.0.0.1` - Desktop: `127.0.0.1`
- Controller: `BACKEND_URL` (публичный URL API) и `FRONTEND_URL` (публичный URL SPA), см. `components/controller/.env-example`
- **CHAIN_ID**: берётся из `curl http://localhost:8888/v1/chain/get_info` после старта ноды - **CHAIN_ID**: берётся из `curl http://localhost:8888/v1/chain/get_info` после старта ноды
- Controller требует `VAPID_PUBLIC_KEY` и `VAPID_PRIVATE_KEY` - Controller требует `VAPID_PUBLIC_KEY` и `VAPID_PRIVATE_KEY`
+4 -2
View File
@@ -1,7 +1,9 @@
NODE_ENV=development NODE_ENV=development
# Заменить на адрес домена (используйтся в ссылке для восстановления доступа) # Публичный URL контроллера (API): на деве обычно порт 2998; на проде может быть https://домен/backend
BASE_URL=http://localhost:3005 BACKEND_URL=http://127.0.0.1:2998
# Публичный URL рабочего стола (SPA): письма, приглашения, deep links в ICS
FRONTEND_URL=http://127.0.0.1:2999
# Port number # Port number
PORT=2998 PORT=2998
+1 -1
View File
@@ -709,7 +709,7 @@ this.providerPort.registerProvider(this.name, this);
Все переменные валидируются Zod-схемой в `src/config/config.ts`. При ошибке — процесс завершается. Все переменные валидируются Zod-схемой в `src/config/config.ts`. При ошибке — процесс завершается.
**Обязательные**: **Обязательные**:
- `NODE_ENV`, `BASE_URL`, `SERVER_SECRET`, `PORT` - `NODE_ENV`, `BACKEND_URL`, `FRONTEND_URL`, `SERVER_SECRET`, `PORT`
- `MONGODB_URL`, `COOPNAME` - `MONGODB_URL`, `COOPNAME`
- `JWT_SECRET` - `JWT_SECRET`
- Переменные PG-подключения: `HOST, PORT, USER, PASS, DB` - Переменные PG-подключения: `HOST, PORT, USER, PASS, DB`
@@ -74,7 +74,7 @@ export class AgendaNotificationService implements OnModuleInit {
itemDescription: ``, itemDescription: ``,
authorName, authorName,
decision_id: agendaId, decision_id: agendaId,
agendaUrl: `${config.base_url}/${action.coopname}/soviet/agenda`, agendaUrl: `${config.frontend_url}/${action.coopname}/soviet/agenda`,
}; };
// Отправляем уведомления каждому члену совета в цикле // Отправляем уведомления каждому члену совета в цикле
@@ -72,7 +72,7 @@ export class DecisionNotificationService implements OnModuleInit {
decisionTitle: `Решение №${decisionId}`, decisionTitle: `Решение №${decisionId}`,
coopname: action.coopname, coopname: action.coopname,
decision_id: decisionId, decision_id: decisionId,
decisionUrl: `${config.base_url}`, decisionUrl: `${config.frontend_url}`,
}; };
// Отправляем уведомление // Отправляем уведомление
@@ -63,7 +63,7 @@ export class AuthInteractor {
throw new Error('User not found'); throw new Error('User not found');
} }
const resetUrl = `${config.base_url}/${config.coopname}/auth/reset-key?token=${resetKeyToken}`; const resetUrl = `${config.frontend_url}/${config.coopname}/auth/reset-key?token=${resetKeyToken}`;
await this.notificationSenderService.sendNotificationToUser(user.username, Workflows.ResetKey.id, { resetUrl }); await this.notificationSenderService.sendNotificationToUser(user.username, Workflows.ResetKey.id, { resetUrl });
} }
@@ -71,7 +71,7 @@ export class AuthInteractor {
async sendVerificationEmail(username: string): Promise<void> { async sendVerificationEmail(username: string): Promise<void> {
const user = await this.userDomainService.getUserByUsername(username); const user = await this.userDomainService.getUserByUsername(username);
const verifyEmailToken = await this.tokenApplicationService.generateVerifyEmailToken(user.id); const verifyEmailToken = await this.tokenApplicationService.generateVerifyEmailToken(user.id);
const verificationUrl = `${config.base_url}/${config.coopname}/auth/verify-email?token=${verifyEmailToken}`; const verificationUrl = `${config.frontend_url}/${config.coopname}/auth/verify-email?token=${verifyEmailToken}`;
await this.notificationSenderService.sendNotificationToUser(user.username, Workflows.EmailVerification.id, { await this.notificationSenderService.sendNotificationToUser(user.username, Workflows.EmailVerification.id, {
verificationUrl, verificationUrl,
@@ -61,7 +61,7 @@ export class PaymentNotificationService implements OnModuleInit {
paymentAmount: payment.quantity.toFixed(2), paymentAmount: payment.quantity.toFixed(2),
paymentCurrency: payment.symbol, paymentCurrency: payment.symbol,
paymentDate: payment.created_at.toLocaleString('ru-RU'), paymentDate: payment.created_at.toLocaleString('ru-RU'),
paymentUrl: `${config.base_url}`, //TODO: точную ссылку потом paymentUrl: `${config.frontend_url}`, //TODO: точную ссылку потом
}; };
// Отправляем уведомление // Отправляем уведомление
@@ -317,7 +317,7 @@ export class ParticipantInteractor {
throw new HttpApiError(http.NOT_FOUND, 'Пользователь не найден'); throw new HttpApiError(http.NOT_FOUND, 'Пользователь не найден');
} }
const token = await this.tokenApplicationService.generateInviteToken(data.email, user.id); const token = await this.tokenApplicationService.generateInviteToken(data.email, user.id);
const inviteUrl = `${config.base_url}/${config.coopname}/auth/invite?token=${token}`; const inviteUrl = `${config.frontend_url}/${config.coopname}/auth/invite?token=${token}`;
await this.notificationSenderService.sendNotificationToUser(data.username, Workflows.Invite.id, { inviteUrl }); await this.notificationSenderService.sendNotificationToUser(data.username, Workflows.Invite.id, { inviteUrl });
@@ -104,7 +104,7 @@ export class ParticipantNotificationService implements OnModuleInit {
paymentCurrency, paymentCurrency,
paymentType: 'Вступительный и минимальный паевой взнос', paymentType: 'Вступительный и минимальный паевой взнос',
coopname, coopname,
paymentUrl: `${config.base_url}`, paymentUrl: `${config.frontend_url}`,
}; };
// Отправляем уведомление // Отправляем уведомление
@@ -205,7 +205,7 @@ export class InstallInteractor {
throw new Error(`Пользователь с email ${member.individual_data.email} не найден`); throw new Error(`Пользователь с email ${member.individual_data.email} не найден`);
} }
const token = await this.tokenApplicationService.generateInviteToken(member.individual_data.email, user.id); const token = await this.tokenApplicationService.generateInviteToken(member.individual_data.email, user.id);
const inviteUrl = `${config.base_url}/${config.coopname}/auth/invite?token=${token}`; const inviteUrl = `${config.frontend_url}/${config.coopname}/auth/invite?token=${token}`;
const payload: Workflows.Invite.IPayload = { const payload: Workflows.Invite.IPayload = {
inviteUrl, inviteUrl,
@@ -64,7 +64,7 @@ export class WalletNotificationService implements OnModuleInit {
paymentCurrency, paymentCurrency,
paymentType: 'Паевой взнос по соглашению о ЦПП "Цифровой Кошелёк"', paymentType: 'Паевой взнос по соглашению о ЦПП "Цифровой Кошелёк"',
coopname, coopname,
paymentUrl: `${config.base_url}`, paymentUrl: `${config.frontend_url}`,
}; };
// Отправляем уведомление // Отправляем уведомление
+10 -2
View File
@@ -6,7 +6,14 @@ dotenv.config({ path: path.join(__dirname, '../../.env') });
const envVarsSchema = z.object({ const envVarsSchema = z.object({
NODE_ENV: z.enum(['production', 'development', 'test']), NODE_ENV: z.enum(['production', 'development', 'test']),
BASE_URL: z.string(), BACKEND_URL: z
.string()
.min(1)
.describe('Публичный базовый URL API (GraphQL/REST), без завершающего слэша; ICS-лента, интеграции'),
FRONTEND_URL: z
.string()
.min(1)
.describe('Публичный базовый URL рабочего стола (SPA); ссылки в письмах и deep links'),
SERVER_SECRET: z.string(), SERVER_SECRET: z.string(),
PORT: z PORT: z
.string() .string()
@@ -149,7 +156,8 @@ if (!envVars.success) {
// Экспорт настроек // Экспорт настроек
export default { export default {
env: envVars.data.NODE_ENV, env: envVars.data.NODE_ENV,
base_url: envVars.data.BASE_URL, backend_url: envVars.data.BACKEND_URL,
frontend_url: envVars.data.FRONTEND_URL,
port: envVars.data.PORT, port: envVars.data.PORT,
server_secret: envVars.data.SERVER_SECRET, server_secret: envVars.data.SERVER_SECRET,
timezone: envVars.data.TIMEZONE, timezone: envVars.data.TIMEZONE,
@@ -48,7 +48,7 @@ function extractHostname(url: string): string {
} }
// Извлекаем hostname из конфигурации // Извлекаем hostname из конфигурации
const hostname = extractHostname(config.base_url); const hostname = extractHostname(config.backend_url);
// Дефолтные параметры конфигурации // Дефолтные параметры конфигурации
export const defaultConfig = { export const defaultConfig = {
@@ -275,7 +275,7 @@ export class GenerationService {
return null; return null;
} }
const pathPrefix = project.isComponent() ? 'components' : 'projects'; const pathPrefix = project.isComponent() ? 'components' : 'projects';
const baseUrl = config.base_url.replace(/\/$/, ''); const baseUrl = config.frontend_url.replace(/\/$/, '');
const path = `/${encodeURIComponent(coopname)}/capital/${pathPrefix}/${encodeURIComponent(anchorProjectHash)}/requirements/${encodeURIComponent(story.story_hash)}`; const path = `/${encodeURIComponent(coopname)}/capital/${pathPrefix}/${encodeURIComponent(anchorProjectHash)}/requirements/${encodeURIComponent(story.story_hash)}`;
const desktopUrl = `${baseUrl}/#${path}`; const desktopUrl = `${baseUrl}/#${path}`;
return [`${story.title}`, desktopUrl].join('\n'); return [`${story.title}`, desktopUrl].join('\n');
@@ -87,7 +87,7 @@ export class ApprovalNotificationService implements OnModuleInit {
authorName, authorName,
coopname: approvalData.coopname, coopname: approvalData.coopname,
approval_hash: approvalData.approval_hash, approval_hash: approvalData.approval_hash,
approvalUrl: `${config.base_url}/${approvalData.coopname}/chairman/approvals`, approvalUrl: `${config.frontend_url}/${approvalData.coopname}/chairman/approvals`,
}; };
// Отправляем уведомление // Отправляем уведомление
@@ -119,7 +119,7 @@ export class ApprovalResponseNotificationService implements OnModuleInit {
approvalId: approvalHash, approvalId: approvalHash,
coopname: config.coopname, coopname: config.coopname,
coopShortName, coopShortName,
approvalUrl: `${config.base_url}`, approvalUrl: `${config.frontend_url}`,
}; };
// Отправляем уведомление // Отправляем уведомление
@@ -144,8 +144,8 @@ export class ChatCoopCalendarApplicationService {
const rawSecret = crypto.randomBytes(32).toString('hex'); const rawSecret = crypto.randomBytes(32).toString('hex');
const hash = sha256Hex(rawSecret); const hash = sha256Hex(rawSecret);
const sub = await this.icsSubs.rotateSecretForUser(coopUsername, hash); const sub = await this.icsSubs.rotateSecretForUser(coopUsername, hash);
const base = config.base_url.replace(/\/$/, ''); const apiBase = config.backend_url.replace(/\/$/, '');
return `${base}/v1/extensions/chatcoop/calendar/feed.ics?id=${encodeURIComponent(sub.id)}&secret=${encodeURIComponent(rawSecret)}`; return `${apiBase}/v1/extensions/chatcoop/calendar/feed.ics?id=${encodeURIComponent(sub.id)}&secret=${encodeURIComponent(rawSecret)}`;
} }
async buildIcsDocumentForSubscription(subscriptionId: string, rawSecret: string): Promise<string | null> { async buildIcsDocumentForSubscription(subscriptionId: string, rawSecret: string): Promise<string | null> {
@@ -158,7 +158,7 @@ export class ChatCoopCalendarApplicationService {
} }
const all = await this.events.listAll(); const all = await this.events.listAll();
const coopname = config.coopname; const coopname = config.coopname;
const baseUrl = config.base_url.replace(/\/$/, ''); const frontendBase = config.frontend_url.replace(/\/$/, '');
const lines: string[] = [ const lines: string[] = [
'BEGIN:VCALENDAR', 'BEGIN:VCALENDAR',
'VERSION:2.0', 'VERSION:2.0',
@@ -171,7 +171,7 @@ export class ChatCoopCalendarApplicationService {
for (const ev of all) { for (const ev of all) {
const room = await this.managedRooms.findByMatrixRoomId(ev.matrixRoomId); const room = await this.managedRooms.findByMatrixRoomId(ev.matrixRoomId);
const roomLabel = room?.displayLabel ?? ev.matrixRoomId; const roomLabel = room?.displayLabel ?? ev.matrixRoomId;
const deepLink = `${baseUrl}/#/${coopname}/chatcoop/chat?matrix_room=${encodeURIComponent(ev.matrixRoomId)}`; const deepLink = `${frontendBase}/#/${coopname}/chatcoop/chat?matrix_room=${encodeURIComponent(ev.matrixRoomId)}`;
const descParts = [ const descParts = [
ev.description ? escapeIcsText(ev.description) : '', ev.description ? escapeIcsText(ev.description) : '',
escapeIcsText(`Комната: ${roomLabel}`), escapeIcsText(`Комната: ${roomLabel}`),
@@ -187,6 +187,8 @@ export class ChatCoopCalendarApplicationService {
lines.push(`SEQUENCE:${ev.icsSequence}`); lines.push(`SEQUENCE:${ev.icsSequence}`);
lines.push(foldIcsLine(`SUMMARY:${escapeIcsText(ev.title)}`)); lines.push(foldIcsLine(`SUMMARY:${escapeIcsText(ev.title)}`));
lines.push(foldIcsLine(`DESCRIPTION:${description}`)); lines.push(foldIcsLine(`DESCRIPTION:${description}`));
// Без VALARM; явно отключаем дефолтное напоминание клиента (часто «за 30 мин») в Apple Calendar.
lines.push('X-APPLE-DEFAULT-ALARM-COMPONENTS:NONE');
lines.push('END:VEVENT'); lines.push('END:VEVENT');
} }
lines.push('END:VCALENDAR'); lines.push('END:VCALENDAR');
@@ -48,7 +48,7 @@ export class MeetWorkflowNotificationService implements OnModuleInit {
// Формирование URL для уведомлений // Формирование URL для уведомлений
private getNotificationUrl(meet: TrackedMeet): string { private getNotificationUrl(meet: TrackedMeet): string {
return `${config.base_url}/${meet.coopname}/user/meets/${meet.hash.toUpperCase()}`; return `${config.frontend_url}/${meet.coopname}/user/meets/${meet.hash.toUpperCase()}`;
} }
// Форматирование сообщения о часовом поясе // Форматирование сообщения о часовом поясе
@@ -81,7 +81,7 @@ export class NotificationSenderService {
// Формирование URL для уведомлений // Формирование URL для уведомлений
private getNotificationUrl(meet: TrackedMeet): string { private getNotificationUrl(meet: TrackedMeet): string {
return `${config.base_url}/${meet.coopname}/user/meets/${meet.hash.toUpperCase()}`; return `${config.frontend_url}/${meet.coopname}/user/meets/${meet.hash.toUpperCase()}`;
} }
// Форматирование сообщения о часовом поясе // Форматирование сообщения о часовом поясе
@@ -35,12 +35,12 @@ export default async function (): Promise<IWorkspaceConfig[]> {
children: [], children: [],
}, },
{ {
path: 'calendar', path: 'mobile',
name: 'chatcoop-calendar', name: 'chatcoop-mobile',
component: markRaw(CalendarPage), component: markRaw(MobileClientPage),
meta: { meta: {
title: 'Календарь событий', title: 'Мобильный клиент',
icon: 'fa-solid fa-calendar-days', icon: 'fa-solid fa-mobile-alt',
roles: ['chairman', 'member', 'user'], roles: ['chairman', 'member', 'user'],
agreements: agreementsBase, agreements: agreementsBase,
requiresAuth: true, requiresAuth: true,
@@ -48,12 +48,12 @@ export default async function (): Promise<IWorkspaceConfig[]> {
children: [], children: [],
}, },
{ {
path: 'mobile', path: 'calendar',
name: 'chatcoop-mobile', name: 'chatcoop-calendar',
component: markRaw(MobileClientPage), component: markRaw(CalendarPage),
meta: { meta: {
title: 'Мобильный клиент', title: 'Календарь событий',
icon: 'fa-solid fa-mobile-alt', icon: 'fa-solid fa-calendar-days',
roles: ['chairman', 'member', 'user'], roles: ['chairman', 'member', 'user'],
agreements: agreementsBase, agreements: agreementsBase,
requiresAuth: true, requiresAuth: true,