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.