feat(notifications): surface guest activity in the admin bell (#849)

* 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
  <Bell> 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.<type> 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.
This commit is contained in:
Paul Nothaft
2026-07-19 22:36:23 +02:00
committed by GitHub
parent e8dad4b40d
commit cb5b319f10
13 changed files with 202 additions and 3 deletions
+5
View File
@@ -164,6 +164,11 @@ async function verifyGalleryAccess(req, res, next) {
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug }); logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event; req.event = event;
req.accessLevel = decoded.accessLevel || 'guest'; 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()}`; req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
// Create client info for logging (similar to secureImageMiddleware but simpler) // Create client info for logging (similar to secureImageMiddleware but simpler)
+75 -1
View File
@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getAppSetting } = require('../utils/appSettings'); const { getAppSetting } = require('../utils/appSettings');
const archiver = require('archiver'); const archiver = require('archiver');
@@ -66,6 +66,54 @@ const fs = require('fs');
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); 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) // Check for slug redirect (for renamed events)
async function checkSlugRedirect(slug) { async function checkSlugRedirect(slug) {
try { try {
@@ -747,6 +795,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
user_agent: req.headers['user-agent'], user_agent: req.headers['user-agent'],
action: 'view' action: 'view'
}); });
notifyGalleryOpened(req.event, req);
} }
// Include protection settings in response // Include protection settings in response
@@ -1003,6 +1052,13 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
action: 'download', action: 'download',
photo_id: photoId 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; let filePath;
try { try {
@@ -1101,6 +1157,8 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
user_agent: req.headers['user-agent'], user_agent: req.headers['user-agent'],
action: 'download_all_presigned' action: 'download_all_presigned'
}).catch(() => {}); }).catch(() => {});
// Surface in the admin notification bell (#746).
logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req));
res.redirect(302, url); res.redirect(302, url);
return; return;
} catch (err) { } catch (err) {
@@ -1124,6 +1182,12 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
user_agent: req.headers['user-agent'], user_agent: req.headers['user-agent'],
action: 'download_all' action: 'download_all'
}).catch(() => {}); }).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; 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(); await archive.finalize();
// Log bulk download // 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 archive.finalize();
await db('access_logs').insert({ await db('access_logs').insert({
+16 -2
View File
@@ -1,6 +1,17 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; 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 <Bell>).
// 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<string, React.ComponentType<{ className?: string }>> = {
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 { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -350,7 +361,10 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`mt-0.5 ${style.color}`}> <div className={`mt-0.5 ${style.color}`}>
<Bell className="w-4 h-4" /> {(() => {
const Icon = NOTIFICATION_ICONS[style.icon] || Bell;
return <Icon className="w-4 h-4" />;
})()}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-neutral-900 dark:text-neutral-100"> <p className="text-sm text-neutral-900 dark:text-neutral-100">
+6
View File
@@ -2497,6 +2497,10 @@
"archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen", "archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen",
"archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht", "archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht",
"archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt", "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}}", "systemActivity": "Systemaktivität: {{type}}",
"adminProfileUpdated": "Admin-Profil aktualisiert von {{actorName}}", "adminProfileUpdated": "Admin-Profil aktualisiert von {{actorName}}",
"photosUploaded_one": "{{count}} Foto in \"{{eventName}}\" hochgeladen", "photosUploaded_one": "{{count}} Foto in \"{{eventName}}\" hochgeladen",
@@ -2728,6 +2732,8 @@
"analytics": "Analytik", "analytics": "Analytik",
"activities": { "activities": {
"event_created": "Neues Ereignis erstellt: {{eventName}}", "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}}", "photos_uploaded": "{{count}} Fotos hochgeladen in {{eventName}}",
"event_archived": "Ereignis archiviert: {{eventName}}", "event_archived": "Ereignis archiviert: {{eventName}}",
"archive_restored": "Archiv wiederhergestellt: {{eventName}}", "archive_restored": "Archiv wiederhergestellt: {{eventName}}",
+6
View File
@@ -2073,6 +2073,10 @@
"archiveDownloaded": "Archive downloaded for \"{{eventName}}\"", "archiveDownloaded": "Archive downloaded for \"{{eventName}}\"",
"archiveDeleted": "Archive deleted for \"{{eventName}}\"", "archiveDeleted": "Archive deleted for \"{{eventName}}\"",
"archiveRestored": "Archive restored 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}}", "systemActivity": "System activity: {{type}}",
"adminProfileUpdated": "Admin profile updated by {{actorName}}", "adminProfileUpdated": "Admin profile updated by {{actorName}}",
"photosUploaded_one": "{{count}} photo uploaded to \"{{eventName}}\"", "photosUploaded_one": "{{count}} photo uploaded to \"{{eventName}}\"",
@@ -2306,6 +2310,8 @@
"analytics": "Analytics", "analytics": "Analytics",
"activities": { "activities": {
"event_created": "New event created: {{eventName}}", "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}}", "photos_uploaded": "{{count}} photos uploaded to {{eventName}}",
"event_archived": "Event archived: {{eventName}}", "event_archived": "Event archived: {{eventName}}",
"archive_restored": "Archive restored: {{eventName}}", "archive_restored": "Archive restored: {{eventName}}",
+6
View File
@@ -1423,6 +1423,10 @@
"archiveDownloaded": "Archivo descargado para \"{{eventName}}\"", "archiveDownloaded": "Archivo descargado para \"{{eventName}}\"",
"archiveDeleted": "Archivo eliminado para \"{{eventName}}\"", "archiveDeleted": "Archivo eliminado para \"{{eventName}}\"",
"archiveRestored": "Archivo restaurado 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}}", "systemActivity": "Actividad del sistema: {{type}}",
"adminProfileUpdated": "Perfil de admin actualizado por {{actorName}}" "adminProfileUpdated": "Perfil de admin actualizado por {{actorName}}"
}, },
@@ -1467,6 +1471,8 @@
"analytics": "Analíticas", "analytics": "Analíticas",
"activities": { "activities": {
"event_created": "Nuevo evento creado: {{eventName}}", "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}}", "photos_uploaded": "{{count}} fotos subidas a {{eventName}}",
"event_archived": "Evento archivado: {{eventName}}", "event_archived": "Evento archivado: {{eventName}}",
"archive_restored": "Archivo restaurado: {{eventName}}", "archive_restored": "Archivo restaurado: {{eventName}}",
+6
View File
@@ -1567,6 +1567,10 @@
"archiveDownloaded": "Archive téléchargée pour \"{{eventName}}\"", "archiveDownloaded": "Archive téléchargée pour \"{{eventName}}\"",
"archiveDeleted": "Archive supprimée pour \"{{eventName}}\"", "archiveDeleted": "Archive supprimée pour \"{{eventName}}\"",
"archiveRestored": "Archive restauré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}}", "systemActivity": "Activité système : {{type}}",
"adminProfileUpdated": "Profil administrateur mis à jour par {{actorName}}", "adminProfileUpdated": "Profil administrateur mis à jour par {{actorName}}",
"photosUploaded_one": "{{count}} photo téléversée dans \"{{eventName}}\"", "photosUploaded_one": "{{count}} photo téléversée dans \"{{eventName}}\"",
@@ -1719,6 +1723,8 @@
"analytics": "Analytique", "analytics": "Analytique",
"activities": { "activities": {
"event_created": "Nouvel événement créé : {{eventName}}", "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}}", "photos_uploaded": "{{count}} photos téléversées dans {{eventName}}",
"event_archived": "Événement archivé : {{eventName}}", "event_archived": "Événement archivé : {{eventName}}",
"archive_restored": "Archive restaurée : {{eventName}}", "archive_restored": "Archive restaurée : {{eventName}}",
+6
View File
@@ -1556,6 +1556,10 @@
"archiveDownloaded": "Archief gedownload voor \"{{eventName}}\"", "archiveDownloaded": "Archief gedownload voor \"{{eventName}}\"",
"archiveDeleted": "Archief verwijderd voor \"{{eventName}}\"", "archiveDeleted": "Archief verwijderd voor \"{{eventName}}\"",
"archiveRestored": "Archief hersteld voor \"{{eventName}}\"", "archiveRestored": "Archief hersteld voor \"{{eventName}}\"",
"galleryOpened": "Een gast heeft de galerij “{{eventName}}” geopend",
"galleryDownloaded": "Een gast heeft fotos uit “{{eventName}}” gedownload",
"galleryDownloadedCustomer": "Klant heeft fotos uit “{{eventName}}” gedownload",
"photoFavorite": "Een foto in “{{eventName}}” is als favoriet gemarkeerd",
"systemActivity": "Systeemactiviteit: {{type}}", "systemActivity": "Systeemactiviteit: {{type}}",
"adminProfileUpdated": "Beheerdersprofiel bijgewerkt door {{actorName}}", "adminProfileUpdated": "Beheerdersprofiel bijgewerkt door {{actorName}}",
"photosUploaded_one": "{{count}} foto geüpload naar \"{{eventName}}\"", "photosUploaded_one": "{{count}} foto geüpload naar \"{{eventName}}\"",
@@ -1708,6 +1712,8 @@
"analytics": "Analyse", "analytics": "Analyse",
"activities": { "activities": {
"event_created": "Nieuw evenement aangemaakt: {{eventName}}", "event_created": "Nieuw evenement aangemaakt: {{eventName}}",
"gallery_opened": "Galerij “{{eventName}}” is geopend",
"gallery_downloaded": "Fotos uit “{{eventName}}” zijn gedownload",
"photos_uploaded": "{{count}} foto's geüpload naar {{eventName}}", "photos_uploaded": "{{count}} foto's geüpload naar {{eventName}}",
"event_archived": "Evenement gearchiveerd: {{eventName}}", "event_archived": "Evenement gearchiveerd: {{eventName}}",
"archive_restored": "Archief hersteld: {{eventName}}", "archive_restored": "Archief hersteld: {{eventName}}",
+6
View File
@@ -1573,6 +1573,10 @@
"archiveDownloaded": "Arquivo baixado para \"{{eventName}}\"", "archiveDownloaded": "Arquivo baixado para \"{{eventName}}\"",
"archiveDeleted": "Arquivo excluído de \"{{eventName}}\"", "archiveDeleted": "Arquivo excluído de \"{{eventName}}\"",
"archiveRestored": "Arquivo restaurado para \"{{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}}", "systemActivity": "Atividade do sistema: {{type}}",
"adminProfileUpdated": "Perfil admin atualizado por {{actorName}}", "adminProfileUpdated": "Perfil admin atualizado por {{actorName}}",
"photosUploaded_many": "{{count}} fotos enviadas para \"{{eventName}}\"", "photosUploaded_many": "{{count}} fotos enviadas para \"{{eventName}}\"",
@@ -1733,6 +1737,8 @@
"analytics": "Análise", "analytics": "Análise",
"activities": { "activities": {
"event_created": "Novo evento criado: {{eventName}}", "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}}", "photos_uploaded": "{{count}} fotos carregadas para {{eventName}}",
"event_archived": "Evento arquivado: {{eventName}}", "event_archived": "Evento arquivado: {{eventName}}",
"archive_restored": "Arquivo restaurado: {{eventName}}", "archive_restored": "Arquivo restaurado: {{eventName}}",
+6
View File
@@ -1590,6 +1590,10 @@
"archiveDownloaded": "Скачан архив для «{{eventName}}»", "archiveDownloaded": "Скачан архив для «{{eventName}}»",
"archiveDeleted": "Архив удалён для «{{eventName}}»", "archiveDeleted": "Архив удалён для «{{eventName}}»",
"archiveRestored": "Архив восстановлен для «{{eventName}}»", "archiveRestored": "Архив восстановлен для «{{eventName}}»",
"galleryOpened": "Гость открыл галерею «{{eventName}}»",
"galleryDownloaded": "Гость скачал фотографии из «{{eventName}}»",
"galleryDownloadedCustomer": "Клиент скачал фотографии из «{{eventName}}»",
"photoFavorite": "Фото из «{{eventName}}» добавлено в избранное",
"systemActivity": "Системная активность: {{type}}", "systemActivity": "Системная активность: {{type}}",
"adminProfileUpdated": "Профиль администратора обновлён пользователем {{actorName}}", "adminProfileUpdated": "Профиль администратора обновлён пользователем {{actorName}}",
"photosUploaded_few": "{{count}} фото загружено в «{{eventName}}»", "photosUploaded_few": "{{count}} фото загружено в «{{eventName}}»",
@@ -1758,6 +1762,8 @@
"analytics": "Аналитика", "analytics": "Аналитика",
"activities": { "activities": {
"event_created": "Новое событие создано: {{eventName}}", "event_created": "Новое событие создано: {{eventName}}",
"gallery_opened": "Галерея «{{eventName}}» была открыта",
"gallery_downloaded": "Фотографии из «{{eventName}}» были скачаны",
"photos_uploaded": "{{count}} фото загружено в {{eventName}}", "photos_uploaded": "{{count}} фото загружено в {{eventName}}",
"event_archived": "Событие архивировано: {{eventName}}", "event_archived": "Событие архивировано: {{eventName}}",
"archive_restored": "Архив восстановлен: {{eventName}}", "archive_restored": "Архив восстановлен: {{eventName}}",
+6
View File
@@ -1556,6 +1556,10 @@
"archiveDownloaded": "Arhiv prenesen za »{{eventName}}«", "archiveDownloaded": "Arhiv prenesen za »{{eventName}}«",
"archiveDeleted": "Arhiv izbrisan za »{{eventName}}«", "archiveDeleted": "Arhiv izbrisan za »{{eventName}}«",
"archiveRestored": "Arhiv obnovljen 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}}", "systemActivity": "Sistemska aktivnost: {{type}}",
"adminProfileUpdated": "Administratorski profil je posodobil {{actorName}}", "adminProfileUpdated": "Administratorski profil je posodobil {{actorName}}",
"photosUploaded_one": "{{count}} fotografija naložena v »{{eventName}}«", "photosUploaded_one": "{{count}} fotografija naložena v »{{eventName}}«",
@@ -1708,6 +1712,8 @@
"analytics": "Analitika", "analytics": "Analitika",
"activities": { "activities": {
"event_created": "Ustvarjen nov dogodek: {{eventName}}", "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}}", "photos_uploaded": "{{count}} fotografij naloženih v {{eventName}}",
"event_archived": "Dogodek arhiviran: {{eventName}}", "event_archived": "Dogodek arhiviran: {{eventName}}",
"archive_restored": "Arhiv obnovljen: {{eventName}}", "archive_restored": "Arhiv obnovljen: {{eventName}}",
@@ -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<Notification, 'type'> = {
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');
});
});
@@ -161,6 +161,13 @@ export const notificationsService = {
case 'feature_flags_updated': case 'feature_flags_updated':
return formatFeatureFlagsChanged(notification.metadata?.changed); 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) ----------------------------------------- // ---- Customer portal (#354) -----------------------------------------
case 'customer_login': case 'customer_login':
return t('admin.notificationMessages.customerLogin', { return t('admin.notificationMessages.customerLogin', {
@@ -425,6 +432,13 @@ export const notificationsService = {
case 'cms_page_logo_uploaded': case 'cms_page_logo_uploaded':
return { icon: 'FileText', color: 'text-green-600' }; 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: default:
return { icon: 'Bell', color: 'text-gray-600' }; return { icon: 'Bell', color: 'text-gray-600' };
} }