From 48d538f94fd39d9b85ec57c57301a8490b7d4f6d Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 4 May 2026 20:56:00 +0200 Subject: [PATCH] feat(events): bulk delete with password confirmation (#384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the bulk-delete half of #384 — admins can select multiple events from the list and delete them in one batch, gated by re-entering their password. ## Why password confirmation Bulk delete is destructive and irreversible (cascades across 5 DB tables and 3 filesystem paths per event). Re-entering the password matches the pattern already used by /auth/admin/change-password and makes accidental clicks much harder than a plain "type DELETE to confirm" — the muscle-memory required to type your real password is a stronger gate than typing a literal word. ## Changes ### Backend (adminEvents.js) - Extracted the per-event cascade-delete logic into a module-private `deleteEventCascade(eventId, adminContext)` helper. The DELETE /:id route now calls it instead of inlining 60 lines of cascade — same behaviour, no drift between the per-event and bulk paths. - New `POST /admin/events/bulk-delete`. Body: `{ eventIds, password }`. Permission: `events.delete`. - Validates `eventIds` array length (1–100) and that each id is an integer. The 100-cap keeps request time bounded; the per-event cascade touches DB + filesystem so 1000 events at once would risk timing out the request. - Verifies `password` against the calling admin's bcrypt hash via `bcrypt.compare()` (same as /auth/admin/change-password). Wrong password → 401 `{ error, code: 'INVALID_PASSWORD' }` and no events are touched. - Loops via `deleteEventCascade`, returns `{ results: { successful, failed } }` with the same shape as /bulk-archive so the frontend can show partial-failure feedback. - Logs `bulk_delete_completed` activity with totals. ### Frontend - `events.service.ts`: `bulkDeleteEvents(eventIds, password)`. - New `BulkDeleteModal.tsx`. Red/destructive variant of the bulk-archive modal: - Lists the events to be deleted (so the admin can verify). - Password input with show/hide toggle, autofocus, Enter-to-submit. - Inline `passwordError` prop surfaces the 401 INVALID_PASSWORD response without losing the modal state — admin can retry without re-typing the event list. - "Processing" state replaces the form with a spinner + "Deleting N events. This may take a few minutes — please don't close this window." (i18n) so admins know not to abandon the page during a slow operation. - `EventsListPage.tsx`: "Delete Selected" button next to "Archive Selected" in the bulk-actions bar (red-styled to signal danger), bulkDeleteMutation that maps the 401 to the modal's inline error and any other failure to a generic toast. ### i18n 12 new keys under `events.bulkDelete.*` in all 5 locales (en/de/nl/pt/ru): title, warning, password label/placeholder/help, submit, processing, incorrectPassword, successAll, successPartial, errorGeneric, plus `events.deleteSelected` for the button. Hand- written for de; nl/pt/ru should get a native-speaker pass at some point but read naturally. ### Verified - `npx tsc --noEmit` clean - `npx eslint` clean on every touched file (4 pre-existing errors in adminEvents.js for unused vars unrelated to this PR) - All 5 locale JSON files parse cleanly - `node -e "require('./src/routes/adminEvents')"` loads the module Closes the bulk-delete half of #384. The Photos-column half lands separately in PR #387. --- backend/src/routes/adminEvents.js | 233 ++++++++++++------ .../src/components/admin/BulkDeleteModal.tsx | 144 +++++++++++ frontend/src/components/admin/index.ts | 1 + frontend/src/i18n/locales/de.json | 14 ++ frontend/src/i18n/locales/en.json | 14 ++ frontend/src/i18n/locales/nl.json | 14 ++ frontend/src/i18n/locales/pt.json | 14 ++ frontend/src/i18n/locales/ru.json | 14 ++ frontend/src/pages/admin/EventsListPage.tsx | 67 ++++- frontend/src/services/events.service.ts | 17 ++ 10 files changed, 450 insertions(+), 82 deletions(-) create mode 100644 frontend/src/components/admin/BulkDeleteModal.tsx diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index d57bcb4b..f3b52113 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -250,6 +250,78 @@ const hasCustomerContactColumns = async () => { } }; +// Cascade-delete a single event: photos, audit/access logs, queued emails, +// the event row itself (in one transaction), then the on-disk folder / +// archive zip / hero logo (best-effort — file failures don't unwind the DB +// changes since the source of truth is the database). Used by both the +// per-event DELETE /:id route and the bulk-delete route to avoid drift. +// +// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the +// bulk-delete loop can report it as a per-id failure without aborting the +// whole batch. Any other error propagates and is the caller's problem. +async function deleteEventCascade(eventId, adminContext) { + const event = await db('events').where('id', eventId).first(); + if (!event) { + const err = new Error('Event not found'); + err.code = 'EVENT_NOT_FOUND'; + throw err; + } + + await db.transaction(async (trx) => { + // 1. Delete activity logs (audit trail) + await trx('activity_logs').where('event_id', eventId).del(); + // 2. Delete access logs + await trx('access_logs').where('event_id', eventId).del(); + // 3. Delete email queue entries + await trx('email_queue').where('event_id', eventId).del(); + // 4. Delete photos (also handles hero_photo_id foreign key) + await trx('photos').where('event_id', eventId).del(); + // 5. Finally delete the event row + await trx('events').where('id', eventId).del(); + + // Best-effort filesystem cleanup. Failures are logged but don't unwind + // the transaction — the canonical state lives in the DB; orphan files + // are recoverable noise, a half-deleted DB row is a permanent mess. + if (event.folder_path) { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path); + try { + await fs.rm(eventFolderPath, { recursive: true, force: true }); + } catch (fsErr) { + logger.warn('Failed to delete event folder during cascade delete', { eventId, path: eventFolderPath, error: fsErr.message }); + } + } + + if (event.archive_path) { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const archiveFile = path.join(storagePath, event.archive_path); + try { + await fs.unlink(archiveFile); + } catch (fsErr) { + logger.warn('Failed to delete archive file during cascade delete', { eventId, path: archiveFile, error: fsErr.message }); + } + } + + if (event.hero_logo_path) { + try { + await fs.unlink(event.hero_logo_path); + } catch (fsErr) { + logger.warn('Failed to delete event logo during cascade delete', { eventId, path: event.hero_logo_path, error: fsErr.message }); + } + } + }); + + // Audit trail (outside the transaction so a logging failure can't undo + // the actual delete). + await logActivity('event_deleted', + { event_name: event.event_name }, + null, + { type: 'admin', id: adminContext.id, name: adminContext.username } + ); + + return { id: event.id, name: event.event_name }; +} + // Create new event router.post('/', adminAuth, requirePermission('events.create'), [ body('event_type').notEmpty().trim().custom(async (value) => { @@ -1238,90 +1310,19 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => { try { const { id } = req.params; - - // Check if event exists - const event = await db('events').where('id', id).first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - // Start a transaction to ensure all deletions succeed or fail together - await db.transaction(async (trx) => { - // 1. Delete activity logs (audit trail) - await trx('activity_logs').where('event_id', id).del(); - - // 2. Delete access logs - await trx('access_logs').where('event_id', id).del(); - - // 3. Delete email queue entries - await trx('email_queue').where('event_id', id).del(); - - // 4. Delete photos (this will also handle hero_photo_id foreign key) - await trx('photos').where('event_id', id).del(); - - // 5. Finally delete the event - await trx('events').where('id', id).del(); - - // Delete event folder from storage if it exists - if (event.folder_path) { - const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); - const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path); - - try { - const fsPromises = require('fs').promises; - await fsPromises.rm(eventFolderPath, { recursive: true, force: true }); - } catch (err) { - console.error('Failed to delete event folder:', err); - // Don't fail the transaction if folder deletion fails - } - } - - // Delete archive if exists - if (event.archive_path) { - const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); - const archivePath = path.join(storagePath, event.archive_path); - - try { - const fsPromises = require('fs').promises; - await fsPromises.unlink(archivePath); - } catch (err) { - console.error('Failed to delete archive file:', err); - // Don't fail the transaction if file deletion fails - } - } - - // Delete custom event logo if exists - if (event.hero_logo_path) { - try { - const fsPromises = require('fs').promises; - await fsPromises.unlink(event.hero_logo_path); - } catch (err) { - logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message }); - } - } - }); - - // Log activity (outside transaction) - await logActivity('event_deleted', - { event_name: event.event_name }, - null, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - + await deleteEventCascade(id, { id: req.admin.id, username: req.admin.username }); res.json({ message: 'Event deleted successfully' }); } catch (error) { - console.error('Error deleting event:', error); - - // Provide more specific error messages + if (error.code === 'EVENT_NOT_FOUND') { + return res.status(404).json({ error: 'Event not found' }); + } + logger.error('Error deleting event', { eventId: req.params.id, error: error.message }); if (error.message && error.message.includes('foreign key constraint')) { - res.status(500).json({ + return res.status(500).json({ error: 'Cannot delete event due to existing references. Please contact support.' }); - } else { - res.status(500).json({ - error: 'Failed to delete event' - }); } + res.status(500).json({ error: 'Failed to delete event' }); } }); @@ -1651,6 +1652,82 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [ } }); +// Bulk delete — destructive, irreversible. Requires the calling admin to +// re-enter their password as a confirmation gate (verified against the +// stored bcrypt hash, same pattern as /auth/admin/change-password). Caps at +// 100 events per request to keep request time bounded; the per-event +// cascade touches 5 DB tables + 3 filesystem paths so 1000 events would +// risk timing out the request. Loops via deleteEventCascade so the per- +// event delete behaviour stays in lock-step with DELETE /:id. +const BULK_DELETE_MAX = 100; +router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [ + body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`), + body('eventIds.*').isInt().withMessage('Each eventId must be an integer'), + body('password').isString().notEmpty().withMessage('Password is required for confirmation') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { eventIds, password } = req.body; + + // Verify the admin's password before doing anything destructive. + // Same pattern as /auth/admin/change-password (auth.js). + const admin = await db('admin_users').where({ id: req.admin.id }).first(); + if (!admin) { + return res.status(401).json({ error: 'Authentication required' }); + } + const validPassword = await bcrypt.compare(password, admin.password_hash); + if (!validPassword) { + logger.warn('Incorrect password on bulk-delete attempt', { adminId: req.admin.id, eventCount: eventIds.length }); + return res.status(401).json({ error: 'Incorrect password', code: 'INVALID_PASSWORD' }); + } + + // Editor-role events.delete permission is already gated by the route + // middleware. We do NOT additionally filter to created_by here because + // the per-event delete-cascade is global (matches DELETE /:id which + // also has no role-based filter — that's why events.delete is a + // sensitive permission). + + const results = { successful: [], failed: [] }; + const adminContext = { id: req.admin.id, username: req.admin.username }; + + for (const eventId of eventIds) { + try { + const deleted = await deleteEventCascade(eventId, adminContext); + results.successful.push(deleted); + } catch (err) { + results.failed.push({ + id: eventId, + name: null, + error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event' + }); + logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message }); + } + } + + await logActivity('bulk_delete_completed', + { + totalEvents: eventIds.length, + successfulCount: results.successful.length, + failedCount: results.failed.length + }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: `Bulk delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, + results + }); + } catch (error) { + logger.error('Error in bulk delete', { error: error.message }); + res.status(500).json({ error: 'Failed to perform bulk delete' }); + } +}); + // Upload event custom logo router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => { try { diff --git a/frontend/src/components/admin/BulkDeleteModal.tsx b/frontend/src/components/admin/BulkDeleteModal.tsx new file mode 100644 index 00000000..1cff874a --- /dev/null +++ b/frontend/src/components/admin/BulkDeleteModal.tsx @@ -0,0 +1,144 @@ +import React, { useState } from 'react'; +import { Trash2, AlertTriangle, X, Lock, Eye, EyeOff, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Card, Input } from '../common'; +import type { Event } from '../../types'; + +interface BulkDeleteModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (password: string) => Promise; + selectedEvents: Event[]; + isLoading?: boolean; + /** Set when the server responded 401 INVALID_PASSWORD; surfaces inline. */ + passwordError?: string | null; + /** Clear the inline password error when the user starts typing again. */ + onPasswordErrorClear?: () => void; +} + +export const BulkDeleteModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + selectedEvents, + isLoading = false, + passwordError = null, + onPasswordErrorClear, +}) => { + const { t } = useTranslation(); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + if (!isOpen) return null; + + const count = selectedEvents.length; + + const handleSubmit = async () => { + if (!password || isLoading) return; + await onConfirm(password); + }; + + const handlePasswordChange = (val: string) => { + setPassword(val); + if (passwordError && onPasswordErrorClear) onPasswordErrorClear(); + }; + + return ( +
+ +
+
+

+ {t('events.bulkDelete.title', 'Permanently delete {{count}} events?', { count })} +

+ +
+ + {/* Processing-state banner replaces the warning + form when in flight. */} + {isLoading ? ( +
+ +

+ {t('events.bulkDelete.processing', 'Deleting {{count}} events. This may take a few minutes — please don\'t close this window.', { count })} +

+
+ ) : ( + <> +
+ +

+ {t('events.bulkDelete.warning', 'This will permanently delete the selected events, all their photos, archives, and audit logs. This action cannot be undone.')} +

+
+ +
+
    + {selectedEvents.map((event) => ( +
  • + • {event.event_name} ({event.event_type}) +
  • + ))} +
+
+ +
+ handlePasswordChange(e.target.value)} + placeholder={t('events.bulkDelete.passwordPlaceholder', 'Your admin password')} + helperText={t('events.bulkDelete.passwordHelp', 'We require your password as a safeguard against accidental bulk deletions.')} + error={passwordError || undefined} + leftIcon={} + rightIcon={ + + } + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && password) handleSubmit(); + }} + /> +
+ +
+ + +
+ + )} +
+
+
+ ); +}; + +BulkDeleteModal.displayName = 'BulkDeleteModal'; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index 61a8c9dd..c1bb95a2 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -10,6 +10,7 @@ export { EventCategoryManager } from './EventCategoryManager'; export { CMSEditor } from './CMSEditor'; export { WelcomeMessageEditor } from './WelcomeMessageEditor'; export { BulkArchiveModal } from './BulkArchiveModal'; +export { BulkDeleteModal } from './BulkDeleteModal'; export { MaintenanceBanner } from './MaintenanceBanner'; export { EmailPreviewModal } from './EmailPreviewModal'; export { AdminPhotoGrid } from './AdminPhotoGrid'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 32b73506..3fa3b6aa 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1058,6 +1058,20 @@ "guestsCannotAccessGallery": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.", "bulkArchiveSuccess": "{{count}} Veranstaltungen erfolgreich archiviert", "bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen", + "deleteSelected": "Ausgewählte löschen", + "bulkDelete": { + "title": "{{count}} Veranstaltungen endgültig löschen?", + "warning": "Die ausgewählten Veranstaltungen, alle ihre Fotos, Archive und Audit-Logs werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", + "passwordLabel": "Zur Bestätigung Ihr Passwort erneut eingeben", + "passwordPlaceholder": "Ihr Admin-Passwort", + "passwordHelp": "Wir benötigen Ihr Passwort als Schutz vor versehentlichen Massenlöschungen.", + "submit": "{{count}} Veranstaltungen löschen", + "processing": "{{count}} Veranstaltungen werden gelöscht. Dies kann einige Minuten dauern — bitte schließen Sie dieses Fenster nicht.", + "incorrectPassword": "Falsches Passwort. Es wurden keine Veranstaltungen gelöscht.", + "successAll": "{{count}} Veranstaltungen endgültig gelöscht", + "successPartial": "{{success}} Veranstaltungen gelöscht, {{failed}} fehlgeschlagen", + "errorGeneric": "Veranstaltungen konnten nicht gelöscht werden" + }, "searchEventsPlaceholder": "Veranstaltungen suchen...", "all": "Alle", "expiring": "Läuft ab", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3689dec0..4a815709 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -578,6 +578,20 @@ "tryAgain": "Try Again", "bulkArchiveSuccess": "Successfully archived {{count}} events", "bulkArchivePartial": "Archived {{success}} events, {{failed}} failed", + "deleteSelected": "Delete Selected", + "bulkDelete": { + "title": "Permanently delete {{count}} events?", + "warning": "This will permanently delete the selected events, all their photos, archives, and audit logs. This action cannot be undone.", + "passwordLabel": "Re-enter your password to confirm", + "passwordPlaceholder": "Your admin password", + "passwordHelp": "We require your password as a safeguard against accidental bulk deletions.", + "submit": "Delete {{count}} events", + "processing": "Deleting {{count}} events. This may take a few minutes — please don't close this window.", + "incorrectPassword": "Incorrect password. No events were deleted.", + "successAll": "Permanently deleted {{count}} events", + "successPartial": "Deleted {{success}} events, {{failed}} failed", + "errorGeneric": "Failed to delete events" + }, "searchEventsPlaceholder": "Search events...", "all": "All", "expiring": "Expiring", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 58a6ab8d..e567d69f 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -572,6 +572,20 @@ "tryAgain": "Opnieuw proberen", "bulkArchiveSuccess": "{{count}} evenementen succesvol gearchiveerd", "bulkArchivePartial": "{{success}} evenementen gearchiveerd, {{failed}} mislukt", + "deleteSelected": "Geselecteerde verwijderen", + "bulkDelete": { + "title": "{{count}} evenementen permanent verwijderen?", + "warning": "De geselecteerde evenementen, al hun foto's, archieven en auditlogboeken worden permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.", + "passwordLabel": "Voer ter bevestiging uw wachtwoord opnieuw in", + "passwordPlaceholder": "Uw beheerderswachtwoord", + "passwordHelp": "We vragen om uw wachtwoord als bescherming tegen onbedoelde bulkverwijderingen.", + "submit": "{{count}} evenementen verwijderen", + "processing": "{{count}} evenementen worden verwijderd. Dit kan enkele minuten duren — sluit dit venster niet.", + "incorrectPassword": "Onjuist wachtwoord. Er zijn geen evenementen verwijderd.", + "successAll": "{{count}} evenementen permanent verwijderd", + "successPartial": "{{success}} evenementen verwijderd, {{failed}} mislukt", + "errorGeneric": "Kan evenementen niet verwijderen" + }, "searchEventsPlaceholder": "Evenementen zoeken...", "all": "Alle", "expiring": "Verloopt binnenkort", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index b9d918fe..5727bc7e 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -572,6 +572,20 @@ "tryAgain": "Tentar Novamente", "bulkArchiveSuccess": "{{count}} eventos arquivados com sucesso", "bulkArchivePartial": "{{success}} eventos arquivados, {{failed}} com falha", + "deleteSelected": "Excluir selecionados", + "bulkDelete": { + "title": "Excluir permanentemente {{count}} eventos?", + "warning": "Os eventos selecionados, todas as suas fotos, arquivos e logs de auditoria serão excluídos permanentemente. Esta ação não pode ser desfeita.", + "passwordLabel": "Digite sua senha novamente para confirmar", + "passwordPlaceholder": "Sua senha de administrador", + "passwordHelp": "Solicitamos sua senha como proteção contra exclusões em massa acidentais.", + "submit": "Excluir {{count}} eventos", + "processing": "Excluindo {{count}} eventos. Isso pode levar alguns minutos — não feche esta janela.", + "incorrectPassword": "Senha incorreta. Nenhum evento foi excluído.", + "successAll": "{{count}} eventos excluídos permanentemente", + "successPartial": "{{success}} eventos excluídos, {{failed}} com falha", + "errorGeneric": "Falha ao excluir eventos" + }, "searchEventsPlaceholder": "Buscar eventos...", "all": "Todos", "expiring": "Expirando", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index e239cdfb..39aed650 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -572,6 +572,20 @@ "tryAgain": "Попробовать снова", "bulkArchiveSuccess": "Успешно архивировано {{count}} событий", "bulkArchivePartial": "Архивировано {{success}} событий, {{failed}} не удалось", + "deleteSelected": "Удалить выбранные", + "bulkDelete": { + "title": "Безвозвратно удалить {{count}} событий?", + "warning": "Выбранные события, все их фотографии, архивы и журналы аудита будут удалены безвозвратно. Это действие невозможно отменить.", + "passwordLabel": "Введите пароль для подтверждения", + "passwordPlaceholder": "Ваш пароль администратора", + "passwordHelp": "Мы запрашиваем пароль для защиты от случайного массового удаления.", + "submit": "Удалить {{count}} событий", + "processing": "Удаление {{count}} событий. Это может занять несколько минут — пожалуйста, не закрывайте это окно.", + "incorrectPassword": "Неверный пароль. События не были удалены.", + "successAll": "Безвозвратно удалено {{count}} событий", + "successPartial": "Удалено {{success}} событий, {{failed}} не удалось", + "errorGeneric": "Не удалось удалить события" + }, "searchEventsPlaceholder": "Поиск событий...", "all": "Все", "expiring": "Истекающие", diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx index a1378cd2..b569622a 100644 --- a/frontend/src/pages/admin/EventsListPage.tsx +++ b/frontend/src/pages/admin/EventsListPage.tsx @@ -23,7 +23,7 @@ import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common'; -import { BulkArchiveModal } from '../../components/admin'; +import { BulkArchiveModal, BulkDeleteModal } from '../../components/admin'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService, type EventStatusFilter } from '../../services/events.service'; import { adminService } from '../../services/admin.service'; @@ -47,6 +47,8 @@ export const EventsListPage: React.FC = () => { const [activeDropdown, setActiveDropdown] = useState(null); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null); const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false); + const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false); + const [bulkDeletePasswordError, setBulkDeletePasswordError] = useState(null); const [copiedEventId, setCopiedEventId] = useState(null); const copyShareLink = async (event: Event) => { @@ -187,7 +189,7 @@ export const EventsListPage: React.FC = () => { queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); setSelectedEvents([]); setShowBulkArchiveModal(false); - + if (data.results.failed.length === 0) { toast.success(t('events.bulkArchiveSuccess', { count: data.results.successful.length })); } else { @@ -199,6 +201,36 @@ export const EventsListPage: React.FC = () => { }, }); + // Bulk delete mutation. The 401 INVALID_PASSWORD response surfaces inline + // on the modal's password field rather than as a toast, since it's a + // recoverable input error (the user can retry without losing context). + const bulkDeleteMutation = useMutation({ + mutationFn: ({ eventIds, password }: { eventIds: number[]; password: string }) => + eventsService.bulkDeleteEvents(eventIds, password), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['admin-events'] }); + queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); + setSelectedEvents([]); + setShowBulkDeleteModal(false); + setBulkDeletePasswordError(null); + + if (data.results.failed.length === 0) { + toast.success(t('events.bulkDelete.successAll', { count: data.results.successful.length })); + } else { + toast.warning(t('events.bulkDelete.successPartial', { success: data.results.successful.length, failed: data.results.failed.length })); + } + }, + onError: (error: unknown) => { + const e = error as { response?: { status?: number; data?: { code?: string; error?: string } } }; + if (e?.response?.status === 401 && e.response.data?.code === 'INVALID_PASSWORD') { + setBulkDeletePasswordError(t('events.bulkDelete.incorrectPassword')); + } else { + toast.error(t('events.bulkDelete.errorGeneric')); + setShowBulkDeleteModal(false); + } + }, + }); + // Filtering and searching now happen server-side. Use the response directly, // ordered as the backend returned them (created_at desc by default). const events: Event[] = data?.events ?? []; @@ -399,13 +431,24 @@ export const EventsListPage: React.FC = () => { - + )} @@ -713,6 +756,22 @@ export const EventsListPage: React.FC = () => { selectedEvents={events.filter(e => selectedEvents.includes(e.id))} isLoading={bulkArchiveMutation.isPending} /> + + {/* Bulk Delete Modal */} + { + setShowBulkDeleteModal(false); + setBulkDeletePasswordError(null); + }} + onConfirm={async (password) => { + await bulkDeleteMutation.mutateAsync({ eventIds: selectedEvents, password }); + }} + selectedEvents={events.filter(e => selectedEvents.includes(e.id))} + isLoading={bulkDeleteMutation.isPending} + passwordError={bulkDeletePasswordError} + onPasswordErrorClear={() => setBulkDeletePasswordError(null)} + /> ); diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index bb5777c2..3e452c48 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -148,6 +148,23 @@ export const eventsService = { return response.data; }, + // Bulk delete events (admin) — destructive. Requires the calling admin's + // password as a server-side confirmation gate. On 401 the server returns + // { error, code: 'INVALID_PASSWORD' } and no events are touched. + async bulkDeleteEvents(eventIds: number[], password: string): Promise<{ + message: string; + results: { + successful: Array<{ id: number; name: string }>; + failed: Array<{ id: number; name: string | null; error: string }>; + }; + }> { + const response = await api.post('/admin/events/bulk-delete', { + eventIds, + password, + }); + return response.data; + }, + // Extend event expiration (admin) async extendExpiration(id: number, days: number): Promise { const response = await api.post(`/events/${id}/extend`, {