From afe3d3b96b913aaeb0b8d952dfdcd6ed046190f8 Mon Sep 17 00:00:00 2001 From: johnmeshulam Date: Tue, 21 Apr 2026 19:26:07 +0300 Subject: [PATCH 01/12] Add SSE for downloading results --- apps/admin/locale/en.json | 1 + apps/admin/locale/he.json | 1 + apps/admin/locale/pl.json | 1 + .../components/download-results-dialog.tsx | 19 ++++- .../components/event-actions-section.tsx | 32 +++----- apps/backend/src/lib/results-download.ts | 14 +++- apps/backend/src/lib/sse.ts | 37 +++++++++ apps/backend/src/lib/temp-download-store.ts | 35 ++++++++ .../routers/admin/events/settings/index.ts | 81 ++++++++++++++----- libs/shared/src/index.ts | 1 + libs/shared/src/lib/sse.ts | 75 +++++++++++++++++ 11 files changed, 251 insertions(+), 46 deletions(-) create mode 100644 apps/backend/src/lib/sse.ts create mode 100644 apps/backend/src/lib/temp-download-store.ts create mode 100644 libs/shared/src/lib/sse.ts diff --git a/apps/admin/locale/en.json b/apps/admin/locale/en.json index 5d7b14f09..cad64eb19 100644 --- a/apps/admin/locale/en.json +++ b/apps/admin/locale/en.json @@ -666,6 +666,7 @@ "message": "Download results as a ZIP file? This will include rubrics and scoresheets for all teams registered in this event.", "event-name": "Event: {eventName}", "info": "The ZIP file will be organized by team slug (e.g.,9999-IL) with PDFs inside each folder.", + "generating": "Generating results...", "confirm": "Download", "cancel": "Cancel", "downloading": "Downloading..." diff --git a/apps/admin/locale/he.json b/apps/admin/locale/he.json index 5036328c6..e70db8beb 100644 --- a/apps/admin/locale/he.json +++ b/apps/admin/locale/he.json @@ -666,6 +666,7 @@ "message": "להוריד תוצאות כקובץ ZIP? זה יכלול מחווני שיפוט ודפי ניקוד לכל הקבוצות הרשומות באירוע זה.", "event-name": "אירוע: {eventName}", "info": "קובץ ה-ZIP יהיה מאורגן לפי סימון הקבוצה (לדוגמה, 9999-IL) עם קבצי PDF בתוך כל תיקייה.", + "generating": "מייצר תוצאות...", "confirm": "הורדה", "cancel": "ביטול", "downloading": "מוריד..." diff --git a/apps/admin/locale/pl.json b/apps/admin/locale/pl.json index 2e195edb0..5b5de8377 100644 --- a/apps/admin/locale/pl.json +++ b/apps/admin/locale/pl.json @@ -666,6 +666,7 @@ "message": "Pobrać wyniki jako plik ZIP? Będzie zawierać rubryki i karty oceny dla wszystkich drużyn zarejestrowanych w tym wydarzeniu.", "event-name": "Wydarzenie: {eventName}", "info": "Plik ZIP będzie zorganizowany według identyfikatora drużyny (np. 9999-IL) z plikami PDF w każdym folderze.", + "generating": "Generowanie wyników...", "confirm": "Pobierz", "cancel": "Anuluj", "downloading": "Pobieranie..." diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx index 396b27608..75f74c916 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/download-results-dialog.tsx @@ -10,13 +10,15 @@ import { DialogActions, Typography, CircularProgress, - Stack + LinearProgress, + Stack, + Box } from '@mui/material'; interface DownloadResultsDialogProps { open: boolean; onClose: () => void; - onConfirm: () => Promise; + onConfirm: (onProgress: (percent: number) => void) => Promise; eventName: string; } @@ -27,14 +29,17 @@ export const DownloadResultsDialog: React.FC = ({ eventName }) => { const [isDownloading, setIsDownloading] = useState(false); + const [progress, setProgress] = useState(null); const t = useTranslations('pages.events.settings.dialogs.download-results'); const handleConfirm = async () => { setIsDownloading(true); + setProgress(0); try { - await onConfirm(); + await onConfirm(setProgress); } finally { setIsDownloading(false); + setProgress(null); } }; @@ -50,6 +55,14 @@ export const DownloadResultsDialog: React.FC = ({ {t('info')} + {progress !== null && ( + + + {t('generating')} + + + + )} diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx index 8e8b8ed99..faef0c400 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx @@ -5,7 +5,7 @@ import { KeyedMutator } from 'swr'; import { useLocale, useTranslations } from 'next-intl'; import { Box, Card, CardContent, Typography, Button, Divider, Stack } from '@mui/material'; import { EventSettings } from '@lems/types/api/admin'; -import { apiFetch } from '@lems/shared'; +import { apiFetch, connectSseStream, getApiBase } from '@lems/shared'; import { useEvent } from '../../components/event-context'; import { CompleteEventDialog } from './complete-event-dialog'; import { PublishEventDialog } from './publish-event-dialog'; @@ -29,7 +29,6 @@ export const EventActionsSection: React.FC = ({ const [completeDialogOpen, setCompleteDialogOpen] = useState(false); const [publishDialogOpen, setPublishDialogOpen] = useState(false); const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); - const [isDownloadLoading, setIsDownloadLoading] = useState(false); const handleCompleteEvent = async () => { setAlert(null); @@ -69,38 +68,30 @@ export const EventActionsSection: React.FC = ({ } }; - const handleDownloadResults = async () => { + const handleDownloadResults = async (onProgress: (percent: number) => void) => { setAlert(null); - setIsDownloadLoading(true); try { - const response = await apiFetch( + const result = await connectSseStream<{ token: string }>( `/admin/events/${event.id}/settings/download?language=${locale}`, + { method: 'POST' }, { - method: 'POST', - responseType: 'binary' + onStart: () => onProgress(0), + onProgress } ); - if (response.ok) { - const blob = response.data as Blob; - const url = window.URL.createObjectURL(blob); + if (result?.token) { const a = document.createElement('a'); - a.href = url; - a.download = `${event.name.replace(/\s+/g, '_')}-results.zip`; + a.href = `${getApiBase(true)}/admin/events/${event.id}/settings/download/file?token=${result.token}`; document.body.appendChild(a); a.click(); a.remove(); - window.URL.revokeObjectURL(url); - - setAlert({ type: 'success', message: t('messages.download-success') }); - setDownloadDialogOpen(false); - } else { - setAlert({ type: 'error', message: t('messages.download-error') }); } + + setAlert({ type: 'success', message: t('messages.download-success') }); + setDownloadDialogOpen(false); } catch { setAlert({ type: 'error', message: t('messages.download-error') }); - } finally { - setIsDownloadLoading(false); } }; @@ -173,7 +164,6 @@ export const EventActionsSection: React.FC = ({ disabled={!settings.published} onClick={() => setDownloadDialogOpen(true)} sx={{ minWidth: 160 }} - loading={isDownloadLoading} > {t('event-actions.download-results')} diff --git a/apps/backend/src/lib/results-download.ts b/apps/backend/src/lib/results-download.ts index 877db51f2..ec943c85f 100644 --- a/apps/backend/src/lib/results-download.ts +++ b/apps/backend/src/lib/results-download.ts @@ -102,14 +102,19 @@ async function getZippedResults( eventSlug: string, archive: archiver.Archiver, language: string, + onProgress?: (percent: number) => void, batchSize: number = 10 ): Promise<{ totalTeams: number; teamsWithPdfs: number; failedPdfs: number; }> { + const registeredTeams = await db.events.byId(eventId).getRegisteredTeams(); + const totalTeamCount = registeredTeams.length; + const teamsWithResults = new Set(); let totalTeams = 0; + let teamsProcessed = 0; let failedPdfs = 0; let batchNumber = 0; @@ -153,6 +158,11 @@ async function getZippedResults( } else { failedPdfs++; } + + teamsProcessed++; + if (totalTeamCount > 0) { + onProgress?.(Math.round((teamsProcessed / totalTeamCount) * 100)); + } } console.info( @@ -189,7 +199,8 @@ async function getZippedResults( */ export async function generateEventResultsZip( eventId: string, - language: string = 'en' + language: string = 'en', + onProgress?: (percent: number) => void ): Promise<{ archive: archiver.Archiver; fileName: string; @@ -210,6 +221,7 @@ export async function generateEventResultsZip( event.slug, archive, language, + onProgress, 5 // Process 5 teams at a time ); diff --git a/apps/backend/src/lib/sse.ts b/apps/backend/src/lib/sse.ts new file mode 100644 index 000000000..a15ff5728 --- /dev/null +++ b/apps/backend/src/lib/sse.ts @@ -0,0 +1,37 @@ +import { Response } from 'express'; +import { SseEvent } from '@lems/shared'; + +export interface SseEmitter { + sendStart(): void; + sendProgress(percent: number, message?: string): void; + sendSuccess(data?: T): void; + sendFailure(message: string): void; +} + +export function createSseEmitter(res: Response): SseEmitter { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const write = (payload: SseEvent) => { + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + + return { + sendStart() { + write({ type: 'start' }); + }, + sendProgress(percent, message) { + write({ type: 'progress', percent, message }); + }, + sendSuccess(data) { + write({ type: 'success', data }); + res.end(); + }, + sendFailure(message) { + write({ type: 'failure', message }); + res.end(); + } + }; +} diff --git a/apps/backend/src/lib/temp-download-store.ts b/apps/backend/src/lib/temp-download-store.ts new file mode 100644 index 000000000..8eaf3f7eb --- /dev/null +++ b/apps/backend/src/lib/temp-download-store.ts @@ -0,0 +1,35 @@ +import fs from 'fs'; +import crypto from 'crypto'; + +const TTL_MS = 30 * 60 * 1000; // 30 minutes + +interface TempFileEntry { + filePath: string; + fileName: string; + createdAt: number; +} + +const store = new Map(); + +function purgeTtl(): void { + const now = Date.now(); + for (const [token, entry] of store.entries()) { + if (now - entry.createdAt > TTL_MS) { + fs.unlink(entry.filePath, () => {}); + store.delete(token); + } + } +} + +export function storeTempFile(filePath: string, fileName: string): string { + purgeTtl(); + const token = crypto.randomUUID(); + store.set(token, { filePath, fileName, createdAt: Date.now() }); + return token; +} + +export function consumeTempFile(token: string): TempFileEntry | null { + const entry = store.get(token) ?? null; + if (entry) store.delete(token); + return entry; +} diff --git a/apps/backend/src/routers/admin/events/settings/index.ts b/apps/backend/src/routers/admin/events/settings/index.ts index 0be1b92f3..21ffdb024 100644 --- a/apps/backend/src/routers/admin/events/settings/index.ts +++ b/apps/backend/src/routers/admin/events/settings/index.ts @@ -1,9 +1,15 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import crypto from 'crypto'; import express from 'express'; import { IntegrationTypes } from '@lems/shared/integrations'; import db from '../../../../lib/database'; import { AdminEventRequest } from '../../../../types/express'; import { publishEventResults } from '../../../integrations/sendgrid/publish'; import { generateEventResultsZip } from '../../../../lib/results-download'; +import { createSseEmitter } from '../../../../lib/sse'; +import { storeTempFile, consumeTempFile } from '../../../../lib/temp-download-store'; import { makeAdminSettingsResponse, makeUpdateableEventSettings } from './util'; const router = express.Router({ mergeParams: true }); @@ -33,6 +39,11 @@ router.post('/complete', async (req: AdminEventRequest, res) => { router.post('/publish', async (req: AdminEventRequest, res) => { try { const settings = await db.events.byId(req.eventId).getSettings(); + if (!settings) { + res.status(404).json({ error: 'Event not found' }); + return; + } + if (!settings.completed) { res.status(400).json({ error: 'Event must be completed before publishing' }); return; @@ -69,10 +80,17 @@ router.post('/publish', async (req: AdminEventRequest, res) => { }); router.post('/download', async (req: AdminEventRequest, res) => { + const emitter = createSseEmitter(res); + try { const settings = await db.events.byId(req.eventId).getSettings(); + if (!settings) { + emitter.sendFailure('Event not found'); + return; + } + if (!settings.published) { - res.status(400).json({ error: 'Event must be published before downloading results' }); + emitter.sendFailure('Event must be published before downloading results'); return; } @@ -81,43 +99,64 @@ router.post('/download', async (req: AdminEventRequest, res) => { `Starting results ZIP generation for event ${req.eventId} (language: ${language})` ); - const { archive, fileName, statistics } = await generateEventResultsZip(req.eventId, language); + emitter.sendStart(); + + const { archive, fileName, statistics } = await generateEventResultsZip( + req.eventId, + language, + percent => emitter.sendProgress(percent) + ); if (statistics.teamsWithPdfs === 0) { console.error( `CRITICAL: Event ${req.eventId} generated an empty ZIP with 0 successful PDFs. ` + - `Total teams: ${statistics.totalTeams}, Failed PDFs: ${statistics.failedPdfs}. ` + - `Check backend logs for PDF generation errors.` + `Total teams: ${statistics.totalTeams}, Failed PDFs: ${statistics.failedPdfs}.` ); - res.status(500).json({ - error: 'Failed to generate results: no PDFs were created.' - }); + emitter.sendFailure('Failed to generate results: no PDFs were created.'); return; } console.info( - `Results ZIP ready for download: ${fileName} ` + + `Results ZIP ready: ${fileName} ` + `(${statistics.teamsWithPdfs}/${statistics.totalTeams} teams with PDFs, ${statistics.failedPdfs} failed PDFs)` ); - res.attachment(fileName); - res.setHeader('Content-Type', 'application/zip'); + const tempPath = path.join(os.tmpdir(), `${crypto.randomUUID()}.zip`); + const fileStream = fs.createWriteStream(tempPath); - archive.on('error', err => { - console.error(`Archive error while generating ${fileName}:`, err); - // Note: Cannot send JSON response here - headers already sent + await new Promise((resolve, reject) => { + fileStream.on('finish', resolve); + fileStream.on('error', reject); + archive.on('error', reject); + archive.pipe(fileStream); + archive.finalize(); }); - archive.pipe(res); - await archive.finalize(); + const token = storeTempFile(tempPath, fileName); + emitter.sendSuccess({ token }); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error( - `Error downloading event results for event ${req.eventId}: ${errorMessage}`, - error - ); - res.status(500).json({ error: `Failed to download event results: ${errorMessage}` }); + const message = error instanceof Error ? error.message : String(error); + console.error(`Error downloading event results for event ${req.eventId}: ${message}`, error); + emitter.sendFailure(`Failed to download event results: ${message}`); + } +}); + +router.get('/download/file', (req: AdminEventRequest, res) => { + const token = req.query.token as string; + if (!token) { + res.status(400).json({ error: 'Missing token' }); + return; + } + + const entry = consumeTempFile(token); + if (!entry) { + res.status(404).json({ error: 'Token not found or expired' }); + return; } + + res.download(entry.filePath, entry.fileName, () => { + fs.unlink(entry.filePath, () => {}); + }); }); export default router; diff --git a/libs/shared/src/index.ts b/libs/shared/src/index.ts index 5583c6ad2..ea0b40283 100644 --- a/libs/shared/src/index.ts +++ b/libs/shared/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/components'; export * from './lib/hooks'; export * from './lib/fetch'; +export * from './lib/sse'; export * from './lib/swr-provider'; export * from './lib/awards'; export * from './lib/deliberation'; diff --git a/libs/shared/src/lib/sse.ts b/libs/shared/src/lib/sse.ts new file mode 100644 index 000000000..09a6787cc --- /dev/null +++ b/libs/shared/src/lib/sse.ts @@ -0,0 +1,75 @@ +import { getApiBase } from './fetch'; + +export type SseEvent = + | { type: 'start' } + | { type: 'progress'; percent: number; message?: string } + | { type: 'success'; data?: T } + | { type: 'failure'; message: string }; + +/** + * Opens an SSE stream to the backend using a fetch-based approach. + * Supports POST (and other methods), unlike native EventSource which only supports GET. + * Automatically includes credentials and the CSRF bypass header. + * + * @param path - API path (e.g. '/admin/events/123/settings/download') + * @param init - Optional fetch RequestInit options (method, headers, body, etc.) + * @param handlers - Optional callbacks for `start` and `progress` events + * @returns A Promise that resolves with the `success` event data, or rejects on `failure` + */ +export async function connectSseStream( + path: string, + init?: RequestInit, + handlers?: { + onStart?: () => void; + onProgress?: (percent: number, message?: string) => void; + } +): Promise { + const response = await fetch(getApiBase() + path, { + ...init, + credentials: 'include', + headers: { + 'x-lems-csrf-enabled': 'true', + ...init?.headers + } + }); + + if (!response.ok || !response.body) { + throw new Error(`SSE request failed: ${response.status} ${response.statusText}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + const parts = buffer.split('\n\n'); + buffer = parts.pop() ?? ''; + + for (const part of parts) { + const line = part.trim(); + if (!line.startsWith('data: ')) continue; + + const event: SseEvent = JSON.parse(line.slice(6)); + + switch (event.type) { + case 'start': + handlers?.onStart?.(); + break; + case 'progress': + handlers?.onProgress?.(event.percent, event.message); + break; + case 'success': + return event.data as T; + case 'failure': + throw new Error(event.message); + } + } + } + + return undefined; +} From 378067640aaf0bdeee160ac9999fb9e63972ebfc Mon Sep 17 00:00:00 2001 From: johnmeshulam Date: Tue, 21 Apr 2026 19:27:47 +0300 Subject: [PATCH 02/12] Fix error --- .../backend/src/routers/integrations/sendgrid/publish.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/backend/src/routers/integrations/sendgrid/publish.ts b/apps/backend/src/routers/integrations/sendgrid/publish.ts index dc382671a..4fa14cb61 100644 --- a/apps/backend/src/routers/integrations/sendgrid/publish.ts +++ b/apps/backend/src/routers/integrations/sendgrid/publish.ts @@ -12,11 +12,20 @@ export interface SendGridPublishOptions { const apiKey = process.env.SENDGRID_API_KEY; +if (!apiKey) { + console.warn('SendGrid API key not configured. Emails will not be sent.'); +} + const sendEmailToContact = async ( event: Event, contact: CSVRecord, emailOptions: { templateId: string; fromAddress: string; language: string } ) => { + if (!apiKey) { + console.warn('SendGrid API key not configured. Skipping email sending.'); + return { success: false, email: contact.recipient_email }; + } + const { templateId, fromAddress, language } = emailOptions; const teamNumber = parseInt(contact.team_number, 10); From 11136ef36493331bbf6c5f70e03abd364e19272e Mon Sep 17 00:00:00 2001 From: johnmeshulam Date: Tue, 21 Apr 2026 19:37:16 +0300 Subject: [PATCH 03/12] Add sse for publish --- apps/admin/locale/en.json | 3 ++ apps/admin/locale/he.json | 3 ++ apps/admin/locale/pl.json | 3 ++ .../components/event-actions-section.tsx | 35 +++++++++--- .../components/publish-event-dialog.tsx | 53 +++++++++++++++---- .../routers/admin/events/settings/index.ts | 45 ++++++++++++---- .../routers/integrations/sendgrid/publish.ts | 10 ++-- 7 files changed, 123 insertions(+), 29 deletions(-) diff --git a/apps/admin/locale/en.json b/apps/admin/locale/en.json index cad64eb19..df08bdea2 100644 --- a/apps/admin/locale/en.json +++ b/apps/admin/locale/en.json @@ -657,6 +657,8 @@ "title": "Publish Event", "message": "Are you sure you want to publish this event? This will make the results public and visible to everyone.", "event-name": "Event: {eventName}", + "info": "If SendGrid integration is enabled, emails will be sent to team contacts with their results.", + "publishing-progress": "Publishing event...", "confirm": "Publish Event", "cancel": "Cancel", "publishing": "Publishing..." @@ -678,6 +680,7 @@ "complete-success": "Event completed successfully", "complete-error": "Failed to complete event", "publish-success": "Event published successfully", + "publish-success-with-emails": "Event published successfully! Sent {emailsSent} emails ({emailsFailed} failed).", "publish-error": "Failed to publish event", "download-success": "Event results downloaded successfully", "download-error": "Failed to download event results", diff --git a/apps/admin/locale/he.json b/apps/admin/locale/he.json index e70db8beb..d3f89fde1 100644 --- a/apps/admin/locale/he.json +++ b/apps/admin/locale/he.json @@ -657,6 +657,8 @@ "title": "פרסום אירוע", "message": "האם אתם בטוחים שברצונכם לפרסם את האירוע? פעולה זו תהפוך את התוצאות לציבוריות ונראות לכולם.", "event-name": "אירוע: {eventName}", + "info": "אם קיימת אינטגרציה עם SendGrid, יישלחו הודעות דוא\"ל ליצירי קשר של הקבוצה עם התוצאות שלהם.", + "publishing-progress": "מפרסם תוצאות...", "confirm": "פרסום אירוע", "cancel": "ביטול", "publishing": "מפרסם..." @@ -678,6 +680,7 @@ "complete-success": "האירוע הושלם בהצלחה", "complete-error": "סיום האירוע נכשל", "publish-success": "האירוע פורסם בהצלחה", + "publish-success-with-emails": "האירוע פורסם בהצלחה! נשלחו {emailsSent} הודעות דוא\"ל ({emailsFailed} נכשלו).", "publish-error": "פרסום האירוע נכשל", "download-success": "תוצאות האירוע הורדו בהצלחה", "download-error": "הורדת תוצאות האירוע נכשלה", diff --git a/apps/admin/locale/pl.json b/apps/admin/locale/pl.json index 5b5de8377..3665a6779 100644 --- a/apps/admin/locale/pl.json +++ b/apps/admin/locale/pl.json @@ -657,6 +657,8 @@ "title": "Opublikuj wydarzenie", "message": "Czy na pewno chcesz opublikować to wydarzenie? Wyniki będą widoczne dla wszystkich.", "event-name": "Wydarzenie: {eventName}", + "info": "Jeśli integracja SendGrid jest włączona, wiadomości e-mail z wynikami będą wysłane do kontaktów drużyn.", + "publishing-progress": "Publikowanie wydarzenia...", "confirm": "Opublikuj wydarzenie", "cancel": "Anuluj", "publishing": "Publikowanie..." @@ -678,6 +680,7 @@ "complete-success": "Wydarzenie zakończono pomyślnie", "complete-error": "Nie udało się zakończyć wydarzenia", "publish-success": "Wydarzenie opublikowano pomyślnie", + "publish-success-with-emails": "Wydarzenie opublikowano pomyślnie! Wysłano {emailsSent} wiadomości e-mail ({emailsFailed} nie powiodło się).", "publish-error": "Nie udało się opublikować wydarzenia", "download-success": "Wyniki wydarzenia pobrano pomyślnie", "download-error": "Nie udało się pobrać wyników wydarzenia", diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx index faef0c400..6bb22d1cd 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/event-actions-section.tsx @@ -49,22 +49,41 @@ export const EventActionsSection: React.FC = ({ } }; - const handlePublishEvent = async () => { + const handlePublishEvent = async (onProgress: (percent: number, message?: string) => void) => { setAlert(null); try { - const response = await apiFetch(`/admin/events/${event.id}/settings/publish`, { - method: 'POST' - }); + const result = await connectSseStream<{ + published: boolean; + emailsSent: number; + emailsFailed: number; + failedEmails?: string[]; + }>( + `/admin/events/${event.id}/settings/publish`, + { method: 'POST' }, + { + onStart: () => onProgress(0), + onProgress: (percent, message) => onProgress(percent, message) + } + ); - if (response.ok) { + if (result?.published) { await mutateSettings(); - setAlert({ type: 'success', message: t('messages.publish-success') }); + const successMsg = + result.emailsSent > 0 + ? t('messages.publish-success-with-emails', { + emailsSent: result.emailsSent, + emailsFailed: result.emailsFailed + }) + : t('messages.publish-success'); + setAlert({ type: 'success', message: successMsg }); setPublishDialogOpen(false); } else { setAlert({ type: 'error', message: t('messages.publish-error') }); } - } catch { - setAlert({ type: 'error', message: t('messages.publish-error') }); + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : t('messages.publish-error'); + setAlert({ type: 'error', message: errorMsg }); } }; diff --git a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/publish-event-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/publish-event-dialog.tsx index 489a2c4f6..187ceabd1 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/publish-event-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/events/[slug]/settings/components/publish-event-dialog.tsx @@ -9,13 +9,17 @@ import { DialogContent, DialogActions, Typography, - CircularProgress + CircularProgress, + LinearProgress, + Stack, + Box, + Alert } from '@mui/material'; interface PublishEventDialogProps { open: boolean; onClose: () => void; - onConfirm: () => Promise; + onConfirm: (onProgress: (percent: number, message?: string) => void) => Promise; eventName: string; } @@ -26,14 +30,23 @@ export const PublishEventDialog: React.FC = ({ eventName }) => { const [isPublishing, setIsPublishing] = useState(false); + const [progress, setProgress] = useState(null); + const [progressMessage, setProgressMessage] = useState(null); const t = useTranslations('pages.events.settings.dialogs.publish-event'); const handleConfirm = async () => { setIsPublishing(true); + setProgress(0); + setProgressMessage(null); try { - await onConfirm(); + await onConfirm((percent, message) => { + setProgress(percent); + if (message) setProgressMessage(message); + }); } finally { setIsPublishing(false); + setProgress(null); + setProgressMessage(null); } }; @@ -41,12 +54,34 @@ export const PublishEventDialog: React.FC = ({ {t('title')} - - {t('message')} - - - {t('event-name', { eventName })} - + + {t('message')} + + {t('event-name', { eventName })} + + + {t('info')} + + {progress !== null && ( + + {progressMessage ? ( + + + {progressMessage} + + + + ) : ( + + + {t('publishing-progress')} + + + + )} + + )} +