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:
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user