From cb5b319f1022655fbc1e442d0d1e6d8337f0e637 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:36:23 +0200 Subject: [PATCH] feat(notifications): surface guest activity in the admin bell (#849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(notifications): surface guest activity in the admin bell (#746) Favorites already reached activity_logs (feedbackService), but gallery opens and downloads only landed in access_logs — invisible in the notification bell. Now: - gallery_opened on the guest photo-list route, debounced in-memory to one notification per event per 6h (the endpoint fires per page load; per-hit notifications would spam the bell). Slideshow traffic stays excluded, matching the analytics exclusion. - gallery_downloaded on all four download paths (streamed + pre-zipped + presigned download-all, download-selected) with scope metadata. - Frontend: locale entries for galleryOpened/galleryDownloaded (and photoFavorite, which previously fell through to the generic 'system activity' line) in all 8 languages — resolved via the existing smart camelCase fallback, no switch cases needed. Distinct bell icons per type. * fix(notifications): single-photo download activity + render per-type bell icons (codex review of #849) - The per-photo Save route (GET /:slug/download/:photoId) only wrote to access_logs — the most common download path never reached the bell. Now emits gallery_downloaded with scope 'single', debounced to one notification per event per hour: a guest saving 30 photos is one signal, not thirty (exact counts stay in access_logs/analytics). - getNotificationStyle's icon names were dead — AdminHeader hard-coded for every row. Added an icon map so gallery opens (Eye), downloads (Download), favorites (Heart) and the pre-existing style names render their intended icons. * fix(notifications): notify after successful delivery, complete the icon map (codex review of #849, round 2) - Single-photo notification now fires on res 'finish' with status < 400: emitting up-front logged downloads that then 404ed/failed AND burned the 1h debounce window against the next real download. - Icon map completed over every name getNotificationStyle returns (grep-verified) — settings/user/mail/etc. styles render their declared icons instead of falling back to Bell. Deliberately NOT taken from the review: DB-backed debounce state for multi-worker deployments. The backend's current deployment contract is single-process (no PM2 cluster in-repo; multi-replica explicitly parked in #799 — chunked-upload/session state is process-local for the same reason). Worst case under a future multi-worker setup is N notifications per window, which degrades, not breaks; a shared-store debounce belongs to the #799 phase-3 work. * fix(notifications): attribute client sessions, log cached-ZIP after finish, add Trash2 icon (codex review of #849, round 3) - gallery_opened/gallery_downloaded now carry the real actor: client sessions (accessLevel 'client') are recorded as 'customer' instead of being mislabeled 'guest' — #746 explicitly covers client activity, so they are attributed, not excluded. - Cached-ZIP streaming path logs on res 'finish' (< 400) like the single-photo path — piping is not delivery. The presigned-redirect and on-the-fly-archiver paths keep their existing timing (redirect handoff / post-finalize). - Trash2 added to the icon map (customer_erased, bulk_delete_completed no longer fall back to Bell — the grep that built the map missed the digit in the name). * fix(notifications): dashboard formatting, portal dedup, actor-aware wording, archiver finish-hooks (codex review of #849, confirmation round) - activity_logs feed TWO surfaces: the dashboard's Recent Activity used admin.activities. keys that didn't exist, rendering raw identifiers — added gallery_opened/gallery_downloaded entries in all 8 locales. - Customer-portal opens already log customer_event_access at the access-token mint; the ensuing /photos call no longer double-notifies (client sessions surface via downloads only). - gallery_downloaded formatting is actor-aware: customer sessions render 'Customer downloaded…' (new galleryDownloadedCustomer key ×8) instead of 'A guest…'. - Both on-the-fly ZIP paths (download-all fallback + download-selected) notify on res 'finish' < 400 — archive.finalize() ends Archiver's input, not the HTTP transfer. * fix(notifications): key customer dedup/attribution on portal provenance, neutral favorite wording (codex review of #849, final round) The previous dedup was inverted: portal-minted tokens carry via:'customer' but NO accessLevel (they run as guest), while PIN-client logins carry accessLevel:'client' and log nothing else. So PIN clients' only open signal was suppressed while portal opens still double- notified and portal downloads read as guest activity. verifyGalleryAccess now surfaces req.viaCustomer; gallery_opened dedups on THAT (portal only), and galleryActor treats via-customer OR accessLevel-client as 'customer'. photoFavorite wording is actor-neutral across all 8 locales — feedbackService logs favorites without an actor, so claiming 'a guest' was wrong for customer favorites. --- backend/src/middleware/gallery.js | 5 ++ backend/src/routes/gallery.js | 76 ++++++++++++++++++- frontend/src/components/admin/AdminHeader.tsx | 18 ++++- frontend/src/i18n/locales/de.json | 6 ++ frontend/src/i18n/locales/en.json | 6 ++ frontend/src/i18n/locales/es.json | 6 ++ frontend/src/i18n/locales/fr.json | 6 ++ frontend/src/i18n/locales/nl.json | 6 ++ frontend/src/i18n/locales/pt.json | 6 ++ frontend/src/i18n/locales/ru.json | 6 ++ frontend/src/i18n/locales/sl.json | 6 ++ .../__tests__/notifications.activity.test.ts | 44 +++++++++++ .../src/services/notifications.service.ts | 14 ++++ 13 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 frontend/src/services/__tests__/notifications.activity.test.ts diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index a1ee968a..97009c16 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -164,6 +164,11 @@ async function verifyGalleryAccess(req, res, next) { logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug }); req.event = event; req.accessLevel = decoded.accessLevel || 'guest'; + // Customer-portal provenance (#746/#849): portal-minted tokens carry + // via:'customer' but NO accessLevel (they default to guest), while + // PIN-client logins carry accessLevel:'client' without `via`. Activity + // attribution/dedup needs the distinction, so surface it explicitly. + req.viaCustomer = decoded.via === 'customer'; req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`; // Create client info for logging (similar to secureImageMiddleware but simpler) diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 8e17607f..6289909e 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -1,6 +1,6 @@ const express = require('express'); const jwt = require('jsonwebtoken'); -const { db } = require('../database/db'); +const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { getAppSetting } = require('../utils/appSettings'); const archiver = require('archiver'); @@ -66,6 +66,54 @@ const fs = require('fs'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +// "Gallery opened" for the admin notification bell (#746). The photo-list +// endpoint fires on every gallery page load, so notifying per hit would spam +// the bell — debounce to at most one notification per event per window. The +// map is in-memory on purpose: losing it on restart merely allows one extra +// notification, and it costs the hot view path zero DB reads. +const GALLERY_OPENED_DEBOUNCE_MS = 6 * 60 * 60 * 1000; // 6h +const galleryOpenedNotifiedAt = new Map(); +// #746 covers CLIENT activity too — attribute the actor from the session +// instead of hard-coding 'guest', so a customer opening from the portal +// isn't mislabeled (codex review of #849 round 3). +function galleryActor(req) { + // Portal tokens run as accessLevel 'guest' but carry via:'customer' + // (req.viaCustomer); PIN-client logins carry accessLevel 'client'. + // Both are customers, not guests (codex review of #849, final round). + const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client')); + return { type: isCustomer ? 'customer' : 'guest' }; +} +function notifyGalleryOpened(event, req) { + // Customer-PORTAL opens already log `customer_event_access` on the + // access-token mint — a second `gallery_opened` per portal click would + // double-notify. Keyed on the portal provenance (req.viaCustomer), NOT + // on accessLevel: PIN-client logins are 'client' without any other + // open signal and must keep notifying (codex review of #849, final + // round — the previous check had this inverted). + if (req && req.viaCustomer) return; + const now = Date.now(); + const last = galleryOpenedNotifiedAt.get(event.id) || 0; + if (now - last < GALLERY_OPENED_DEBOUNCE_MS) return; + galleryOpenedNotifiedAt.set(event.id, now); + // Fire-and-forget — logActivity swallows its own errors. + logActivity('gallery_opened', {}, event.id, galleryActor(req)); +} + +// Single-photo saves are frequent (a guest saving 30 photos = 30 route +// hits) — debounce like gallery_opened so the bell gets one "guest is +// downloading photos" signal per event per window instead of a flood +// (codex review of #849). ZIP downloads stay un-debounced: rare, high +// signal. Exact per-photo counts remain in access_logs/analytics. +const SINGLE_DOWNLOAD_DEBOUNCE_MS = 60 * 60 * 1000; // 1h +const singleDownloadNotifiedAt = new Map(); +function notifySinglePhotoDownload(event, req) { + const now = Date.now(); + const last = singleDownloadNotifiedAt.get(event.id) || 0; + if (now - last < SINGLE_DOWNLOAD_DEBOUNCE_MS) return; + singleDownloadNotifiedAt.set(event.id, now); + logActivity('gallery_downloaded', { scope: 'single' }, event.id, galleryActor(req)); +} + // Check for slug redirect (for renamed events) async function checkSlugRedirect(slug) { try { @@ -747,6 +795,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) user_agent: req.headers['user-agent'], action: 'view' }); + notifyGalleryOpened(req.event, req); } // Include protection settings in response @@ -1003,6 +1052,13 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, action: 'download', photo_id: photoId }); + // Surface in the admin notification bell (#746) — debounced, and only + // once the response actually finished: notifying up-front would log a + // download that then 404s/fails and the debounce would suppress the + // next real one for an hour (codex review of #849). + res.on('finish', () => { + if (res.statusCode < 400) notifySinglePhotoDownload(req.event, req); + }); let filePath; try { @@ -1101,6 +1157,8 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async user_agent: req.headers['user-agent'], action: 'download_all_presigned' }).catch(() => {}); + // Surface in the admin notification bell (#746). + logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req)); res.redirect(302, url); return; } catch (err) { @@ -1124,6 +1182,12 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async user_agent: req.headers['user-agent'], action: 'download_all' }).catch(() => {}); + // Surface in the admin notification bell (#746) — only once the + // stream actually finished; logging at pipe-time would report + // downloads that then broke mid-transfer (codex review of #849). + res.on('finish', () => { + if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req)); + }); return; } @@ -1228,6 +1292,12 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async } } + // Notification only after the response actually finished — finalize() + // ends Archiver's input, not the HTTP transfer (codex review of #849, + // confirmation round). Registered before finalize so it can't be missed. + res.on('finish', () => { + if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req)); + }); await archive.finalize(); // Log bulk download @@ -1346,6 +1416,10 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, } } + // See download-all: notify only on response 'finish'. + res.on('finish', () => { + if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'selected', photo_count: photoIds.length }, req.event.id, galleryActor(req)); + }); await archive.finalize(); await db('access_logs').insert({ diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 0f4c22c5..ab0a23c8 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -1,6 +1,17 @@ import React, { useState, useRef, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon, Globe, ChevronDown } from 'lucide-react'; +import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon, Globe, ChevronDown, Eye, Download, Heart, Calendar, Image, Archive, AlertCircle, Clock, Database, FileText, Folder, Key, Mail, Tag, ToggleRight, UserCog, Webhook } from 'lucide-react'; + +// getNotificationStyle returns an icon NAME — map the ones we render to +// components; anything unmapped keeps the Bell (codex review of #849: +// the styles existed but every row hard-coded ). +// Complete over getNotificationStyle's icon names (grep-verified) so every +// style renders its declared icon (codex review of #849 round 2). +const NOTIFICATION_ICONS: Record> = { + Bell, Eye, Download, Heart, Calendar, Image, Archive, AlertCircle, Clock, Lock, + CheckCircle, Database, FileText, Folder, Globe, Key, LogOut, Mail, Settings, + Tag, ToggleRight, Trash2, User, UserCog, Webhook, +}; import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -350,7 +361,10 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { >
- + {(() => { + const Icon = NOTIFICATION_ICONS[style.icon] || Bell; + return ; + })()}

diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 782f305a..66fdb25d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2497,6 +2497,10 @@ "archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen", "archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht", "archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt", + "galleryOpened": "Ein Gast hat die Galerie „{{eventName}}“ geöffnet", + "galleryDownloaded": "Ein Gast hat Fotos aus „{{eventName}}“ heruntergeladen", + "galleryDownloadedCustomer": "Kunde hat Fotos aus „{{eventName}}“ heruntergeladen", + "photoFavorite": "Ein Foto in „{{eventName}}“ wurde favorisiert", "systemActivity": "Systemaktivität: {{type}}", "adminProfileUpdated": "Admin-Profil aktualisiert von {{actorName}}", "photosUploaded_one": "{{count}} Foto in \"{{eventName}}\" hochgeladen", @@ -2728,6 +2732,8 @@ "analytics": "Analytik", "activities": { "event_created": "Neues Ereignis erstellt: {{eventName}}", + "gallery_opened": "Galerie „{{eventName}}“ wurde geöffnet", + "gallery_downloaded": "Fotos aus „{{eventName}}“ wurden heruntergeladen", "photos_uploaded": "{{count}} Fotos hochgeladen in {{eventName}}", "event_archived": "Ereignis archiviert: {{eventName}}", "archive_restored": "Archiv wiederhergestellt: {{eventName}}", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e7d77ddc..0aa24dcc 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2073,6 +2073,10 @@ "archiveDownloaded": "Archive downloaded for \"{{eventName}}\"", "archiveDeleted": "Archive deleted for \"{{eventName}}\"", "archiveRestored": "Archive restored for \"{{eventName}}\"", + "galleryOpened": "A guest opened the gallery “{{eventName}}”", + "galleryDownloaded": "A guest downloaded photos from “{{eventName}}”", + "galleryDownloadedCustomer": "Customer downloaded photos from “{{eventName}}”", + "photoFavorite": "A photo in “{{eventName}}” was favorited", "systemActivity": "System activity: {{type}}", "adminProfileUpdated": "Admin profile updated by {{actorName}}", "photosUploaded_one": "{{count}} photo uploaded to \"{{eventName}}\"", @@ -2306,6 +2310,8 @@ "analytics": "Analytics", "activities": { "event_created": "New event created: {{eventName}}", + "gallery_opened": "Gallery “{{eventName}}” was opened", + "gallery_downloaded": "Photos were downloaded from “{{eventName}}”", "photos_uploaded": "{{count}} photos uploaded to {{eventName}}", "event_archived": "Event archived: {{eventName}}", "archive_restored": "Archive restored: {{eventName}}", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 49e4e38b..dcf38e6f 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -1423,6 +1423,10 @@ "archiveDownloaded": "Archivo descargado para \"{{eventName}}\"", "archiveDeleted": "Archivo eliminado para \"{{eventName}}\"", "archiveRestored": "Archivo restaurado para \"{{eventName}}\"", + "galleryOpened": "Un invitado abrió la galería «{{eventName}}»", + "galleryDownloaded": "Un invitado descargó fotos de «{{eventName}}»", + "galleryDownloadedCustomer": "El cliente descargó fotos de «{{eventName}}»", + "photoFavorite": "Se marcó como favorita una foto de «{{eventName}}»", "systemActivity": "Actividad del sistema: {{type}}", "adminProfileUpdated": "Perfil de admin actualizado por {{actorName}}" }, @@ -1467,6 +1471,8 @@ "analytics": "Analíticas", "activities": { "event_created": "Nuevo evento creado: {{eventName}}", + "gallery_opened": "Se abrió la galería «{{eventName}}»", + "gallery_downloaded": "Se descargaron fotos de «{{eventName}}»", "photos_uploaded": "{{count}} fotos subidas a {{eventName}}", "event_archived": "Evento archivado: {{eventName}}", "archive_restored": "Archivo restaurado: {{eventName}}", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 504559de..d5410eb2 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -1567,6 +1567,10 @@ "archiveDownloaded": "Archive téléchargée pour \"{{eventName}}\"", "archiveDeleted": "Archive supprimée pour \"{{eventName}}\"", "archiveRestored": "Archive restaurée pour \"{{eventName}}\"", + "galleryOpened": "Un invité a ouvert la galerie « {{eventName}} »", + "galleryDownloaded": "Un invité a téléchargé des photos de « {{eventName}} »", + "galleryDownloadedCustomer": "Le client a téléchargé des photos de « {{eventName}} »", + "photoFavorite": "Une photo de « {{eventName}} » a été mise en favori", "systemActivity": "Activité système : {{type}}", "adminProfileUpdated": "Profil administrateur mis à jour par {{actorName}}", "photosUploaded_one": "{{count}} photo téléversée dans \"{{eventName}}\"", @@ -1719,6 +1723,8 @@ "analytics": "Analytique", "activities": { "event_created": "Nouvel événement créé : {{eventName}}", + "gallery_opened": "La galerie « {{eventName}} » a été ouverte", + "gallery_downloaded": "Des photos de « {{eventName}} » ont été téléchargées", "photos_uploaded": "{{count}} photos téléversées dans {{eventName}}", "event_archived": "Événement archivé : {{eventName}}", "archive_restored": "Archive restaurée : {{eventName}}", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index fc375d58..362fba51 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -1556,6 +1556,10 @@ "archiveDownloaded": "Archief gedownload voor \"{{eventName}}\"", "archiveDeleted": "Archief verwijderd voor \"{{eventName}}\"", "archiveRestored": "Archief hersteld voor \"{{eventName}}\"", + "galleryOpened": "Een gast heeft de galerij “{{eventName}}” geopend", + "galleryDownloaded": "Een gast heeft foto’s uit “{{eventName}}” gedownload", + "galleryDownloadedCustomer": "Klant heeft foto’s uit “{{eventName}}” gedownload", + "photoFavorite": "Een foto in “{{eventName}}” is als favoriet gemarkeerd", "systemActivity": "Systeemactiviteit: {{type}}", "adminProfileUpdated": "Beheerdersprofiel bijgewerkt door {{actorName}}", "photosUploaded_one": "{{count}} foto geüpload naar \"{{eventName}}\"", @@ -1708,6 +1712,8 @@ "analytics": "Analyse", "activities": { "event_created": "Nieuw evenement aangemaakt: {{eventName}}", + "gallery_opened": "Galerij “{{eventName}}” is geopend", + "gallery_downloaded": "Foto’s uit “{{eventName}}” zijn gedownload", "photos_uploaded": "{{count}} foto's geüpload naar {{eventName}}", "event_archived": "Evenement gearchiveerd: {{eventName}}", "archive_restored": "Archief hersteld: {{eventName}}", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index e92bdb51..79340692 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -1573,6 +1573,10 @@ "archiveDownloaded": "Arquivo baixado para \"{{eventName}}\"", "archiveDeleted": "Arquivo excluído de \"{{eventName}}\"", "archiveRestored": "Arquivo restaurado para \"{{eventName}}\"", + "galleryOpened": "Um convidado abriu a galeria “{{eventName}}”", + "galleryDownloaded": "Um convidado baixou fotos de “{{eventName}}”", + "galleryDownloadedCustomer": "O cliente baixou fotos de “{{eventName}}”", + "photoFavorite": "Uma foto em “{{eventName}}” foi favoritada", "systemActivity": "Atividade do sistema: {{type}}", "adminProfileUpdated": "Perfil admin atualizado por {{actorName}}", "photosUploaded_many": "{{count}} fotos enviadas para \"{{eventName}}\"", @@ -1733,6 +1737,8 @@ "analytics": "Análise", "activities": { "event_created": "Novo evento criado: {{eventName}}", + "gallery_opened": "A galeria “{{eventName}}” foi aberta", + "gallery_downloaded": "Fotos de “{{eventName}}” foram baixadas", "photos_uploaded": "{{count}} fotos carregadas para {{eventName}}", "event_archived": "Evento arquivado: {{eventName}}", "archive_restored": "Arquivo restaurado: {{eventName}}", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 88a215f3..f91e3b9d 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -1590,6 +1590,10 @@ "archiveDownloaded": "Скачан архив для «{{eventName}}»", "archiveDeleted": "Архив удалён для «{{eventName}}»", "archiveRestored": "Архив восстановлен для «{{eventName}}»", + "galleryOpened": "Гость открыл галерею «{{eventName}}»", + "galleryDownloaded": "Гость скачал фотографии из «{{eventName}}»", + "galleryDownloadedCustomer": "Клиент скачал фотографии из «{{eventName}}»", + "photoFavorite": "Фото из «{{eventName}}» добавлено в избранное", "systemActivity": "Системная активность: {{type}}", "adminProfileUpdated": "Профиль администратора обновлён пользователем {{actorName}}", "photosUploaded_few": "{{count}} фото загружено в «{{eventName}}»", @@ -1758,6 +1762,8 @@ "analytics": "Аналитика", "activities": { "event_created": "Новое событие создано: {{eventName}}", + "gallery_opened": "Галерея «{{eventName}}» была открыта", + "gallery_downloaded": "Фотографии из «{{eventName}}» были скачаны", "photos_uploaded": "{{count}} фото загружено в {{eventName}}", "event_archived": "Событие архивировано: {{eventName}}", "archive_restored": "Архив восстановлен: {{eventName}}", diff --git a/frontend/src/i18n/locales/sl.json b/frontend/src/i18n/locales/sl.json index af025f9f..a2698293 100644 --- a/frontend/src/i18n/locales/sl.json +++ b/frontend/src/i18n/locales/sl.json @@ -1556,6 +1556,10 @@ "archiveDownloaded": "Arhiv prenesen za »{{eventName}}«", "archiveDeleted": "Arhiv izbrisan za »{{eventName}}«", "archiveRestored": "Arhiv obnovljen za »{{eventName}}«", + "galleryOpened": "Gost je odprl galerijo »{{eventName}}«", + "galleryDownloaded": "Gost je prenesel fotografije iz »{{eventName}}«", + "galleryDownloadedCustomer": "Stranka je prenesla fotografije iz »{{eventName}}«", + "photoFavorite": "Fotografija v »{{eventName}}« je bila označena kot priljubljena", "systemActivity": "Sistemska aktivnost: {{type}}", "adminProfileUpdated": "Administratorski profil je posodobil {{actorName}}", "photosUploaded_one": "{{count}} fotografija naložena v »{{eventName}}«", @@ -1708,6 +1712,8 @@ "analytics": "Analitika", "activities": { "event_created": "Ustvarjen nov dogodek: {{eventName}}", + "gallery_opened": "Galerija »{{eventName}}« je bila odprta", + "gallery_downloaded": "Fotografije iz »{{eventName}}« so bile prenesene", "photos_uploaded": "{{count}} fotografij naloženih v {{eventName}}", "event_archived": "Dogodek arhiviran: {{eventName}}", "archive_restored": "Arhiv obnovljen: {{eventName}}", diff --git a/frontend/src/services/__tests__/notifications.activity.test.ts b/frontend/src/services/__tests__/notifications.activity.test.ts new file mode 100644 index 00000000..e182c890 --- /dev/null +++ b/frontend/src/services/__tests__/notifications.activity.test.ts @@ -0,0 +1,44 @@ +/** + * Client-activity notification types (#746): gallery_opened / + * gallery_downloaded (new logActivity calls in gallery.js) and + * photo_favorite (logged by feedbackService since #400-era) must resolve + * through the smart camelCase fallback in formatNotificationMessage — + * i.e. the locale keys must exist, otherwise the bell renders the generic + * "system activity" line. + */ +import { describe, it, expect } from 'vitest'; +import i18n from '../../i18n/config'; +import { notificationsService, type Notification } from '../notifications.service'; + +const base: Omit = { + id: 1, + actorType: 'guest', + actorName: null, + eventName: 'Anna & Tom', + eventId: 42, + metadata: {}, + createdAt: '2026-07-19T12:00:00Z', + readAt: null, + isRead: false, +} as any; + +describe('client-activity notifications (#746)', () => { + it.each(['gallery_opened', 'gallery_downloaded', 'photo_favorite'])( + 'formats %s via a real locale key (no generic fallback)', + async (type) => { + await i18n.changeLanguage('en'); + const msg = notificationsService.formatNotificationMessage({ ...base, type } as Notification); + expect(msg).toContain('Anna & Tom'); + expect(msg.toLowerCase()).not.toContain('system activity'); + } + ); + + it('has a distinct style for each new type', () => { + const opened = notificationsService.getNotificationStyle('gallery_opened'); + const downloaded = notificationsService.getNotificationStyle('gallery_downloaded'); + const favorite = notificationsService.getNotificationStyle('photo_favorite'); + expect(opened.icon).toBe('Eye'); + expect(downloaded.icon).toBe('Download'); + expect(favorite.icon).toBe('Heart'); + }); +}); diff --git a/frontend/src/services/notifications.service.ts b/frontend/src/services/notifications.service.ts index 66129a16..f1902cfb 100644 --- a/frontend/src/services/notifications.service.ts +++ b/frontend/src/services/notifications.service.ts @@ -161,6 +161,13 @@ export const notificationsService = { case 'feature_flags_updated': return formatFeatureFlagsChanged(notification.metadata?.changed); + // Client activity (#746): downloads carry the real actor — customer + // sessions must not read as "A guest…" (codex review of #849). + case 'gallery_downloaded': + return notification.actorType === 'customer' + ? t('admin.notificationMessages.galleryDownloadedCustomer', { eventName: notification.eventName }) + : t('admin.notificationMessages.galleryDownloaded', { eventName: notification.eventName }); + // ---- Customer portal (#354) ----------------------------------------- case 'customer_login': return t('admin.notificationMessages.customerLogin', { @@ -425,6 +432,13 @@ export const notificationsService = { case 'cms_page_logo_uploaded': return { icon: 'FileText', color: 'text-green-600' }; + // Client activity (#746) — guest actions surfaced to the photographer. + case 'gallery_opened': + return { icon: 'Eye', color: 'text-blue-600' }; + case 'gallery_downloaded': + return { icon: 'Download', color: 'text-green-600' }; + case 'photo_favorite': + return { icon: 'Heart', color: 'text-pink-600' }; default: return { icon: 'Bell', color: 'text-gray-600' }; }