fix(activity-log): smart feature_flags_updated rendering + 33 missing types
The Dashboard "Recent Activity" widget and the header notifications dropdown both rendered raw activity-type strings (e.g. the literal "feature_flags_updated") for any type missing from their lookup maps — including everything emitted by the recently-added customer portal (#354), webhooks (#327), API tokens (#322), event types, event-publish flow, admin user management (#350), and the feature-flags reorg itself. Two coordinated changes: 1. Smart formatter for feature_flags_updated. The backend writes `metadata.changed = { [flagKey]: { from, to } }` on every save. New formatFeatureFlagsChanged() helper in admin.service.ts reads that diff and renders: - 1 change → "Customer Portal enabled" - N changes → "3 features updated: Customer Portal enabled, Calendar disabled, Quotes enabled" Per-flag display labels source from `settings.features.<key>.title` so they stay in sync with the Features tab. Unknown flag keys fall through to a humanised version of the key. 2. 33 missing activity types added to BOTH renderers and to the `admin.activities.*` + `admin.notificationMessages.*` i18n namespaces across all six locales. Coverage groups: customer portal (13 types), admin user management (6), webhooks (3), API tokens (2), event types (4), event publish/logo (3), bulk delete (1), and assorted post-merge surfaces (4). The notifications.service.ts switch + admin.service.ts fallback message map are still duplicated; consolidating them into a single source of truth is a follow-up worth doing before the next significant addition. For now both stay in sync via this PR. en + de hand-translated. nl + pt + ru + fr machine-translated and flagged for native review per project convention.
This commit is contained in:
@@ -1,4 +1,61 @@
|
||||
import { api } from '../config/api';
|
||||
import i18n from '../i18n/config';
|
||||
|
||||
/**
|
||||
* Per-flag display labels for activity-log rendering. Values are
|
||||
* i18n keys; if a key is missing in the active locale we fall back
|
||||
* to a humanised version of the flag name. Kept in sync with
|
||||
* `FeatureKey` in services/featureFlags.service.ts.
|
||||
*/
|
||||
const FEATURE_FLAG_LABEL_KEY: Record<string, string> = {
|
||||
galleries: 'settings.features.galleries.title',
|
||||
reminderEmails: 'settings.features.reminderEmails.title',
|
||||
calendar: 'settings.features.calendar.title',
|
||||
calendarBooking: 'settings.features.calendarBooking.title',
|
||||
quotes: 'settings.features.quotes.title',
|
||||
bills: 'settings.features.bills.title',
|
||||
messaging: 'settings.features.messaging.title',
|
||||
analytics: 'settings.features.analytics.title',
|
||||
userManagement: 'settings.features.userManagement.title',
|
||||
clients: 'settings.features.clients.title',
|
||||
customerPortal: 'settings.features.customerPortal.title',
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the `feature_flags_updated` activity row using the
|
||||
* `metadata.changed = { [flagKey]: { from, to } }` payload the
|
||||
* backend writes. One change → "Calendar enabled". Multiple →
|
||||
* "3 features updated: Calendar enabled, Quotes disabled, …".
|
||||
*
|
||||
* Used by both the Dashboard recent-activity widget (via
|
||||
* formatActivityMessage below) and the header notification
|
||||
* dropdown (via notifications.service.ts).
|
||||
*/
|
||||
export function formatFeatureFlagsChanged(
|
||||
changed: Record<string, { from: boolean; to: boolean }> | undefined,
|
||||
): string {
|
||||
const t = i18n.t;
|
||||
const entries = Object.entries(changed || {});
|
||||
if (entries.length === 0) {
|
||||
return t('admin.activities.feature_flags_noop', 'Feature flags reviewed (no changes)');
|
||||
}
|
||||
const pieces = entries.map(([key, change]) => {
|
||||
const labelKey = FEATURE_FLAG_LABEL_KEY[key];
|
||||
// Humanise unknown keys (camelCase → "Camel Case") so a forward-
|
||||
// compat flag added in a newer release still renders something
|
||||
// readable on a frontend that doesn't know about it yet.
|
||||
const fallback = key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
|
||||
const label = labelKey ? (t(labelKey, fallback) as string) : fallback;
|
||||
return change.to
|
||||
? (t('admin.activities.feature_enabled', '{{label}} enabled', { label }) as string)
|
||||
: (t('admin.activities.feature_disabled', '{{label}} disabled', { label }) as string);
|
||||
});
|
||||
if (pieces.length === 1) return pieces[0];
|
||||
return t('admin.activities.feature_flags_summary', '{{count}} features updated: {{summary}}', {
|
||||
count: pieces.length,
|
||||
summary: pieces.join(', '),
|
||||
}) as string;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
activeEvents: number;
|
||||
@@ -88,6 +145,51 @@ export type ActivityType =
|
||||
| "admin_logout"
|
||||
| "system_activity"
|
||||
| "unknown"
|
||||
// Feature flag toggles emit one row per save with the full
|
||||
// { changed: { key: { from, to } } } diff in metadata.
|
||||
| "feature_flags_updated"
|
||||
// Customer portal (#354).
|
||||
| "customer_login"
|
||||
| "customer_invitation_created"
|
||||
| "customer_invitation_accepted"
|
||||
| "customer_invitation_cancelled"
|
||||
| "customer_password_reset_requested"
|
||||
| "customer_password_reset_applied"
|
||||
| "customer_password_change"
|
||||
| "customer_self_profile_update"
|
||||
| "customer_event_access"
|
||||
| "customer_updated"
|
||||
| "customer_deactivated"
|
||||
| "customer_reactivated"
|
||||
| "customer_erased"
|
||||
// Admin user management (#350).
|
||||
| "admin_invitation_created"
|
||||
| "admin_invitation_accepted"
|
||||
| "admin_invitation_cancelled"
|
||||
| "admin_user_updated"
|
||||
| "admin_user_deactivated"
|
||||
| "admin_password_reset"
|
||||
| "admin_profile_updated"
|
||||
// Webhooks (#327) + API tokens (#322) + event types.
|
||||
| "webhook_created"
|
||||
| "webhook_updated"
|
||||
| "webhook_deleted"
|
||||
| "api_token_created"
|
||||
| "api_token_revoked"
|
||||
| "event_type_created"
|
||||
| "event_type_updated"
|
||||
| "event_type_deleted"
|
||||
| "event_types_reordered"
|
||||
// Other recent surfaces missing from the message map.
|
||||
| "event_published"
|
||||
| "event_logo_uploaded"
|
||||
| "event_logo_removed"
|
||||
| "bulk_delete_completed"
|
||||
| "photo_replaced"
|
||||
| "photo_uploaded"
|
||||
| "category_hero_updated"
|
||||
| "public_site_reset_to_default"
|
||||
| "cms_page_logo_uploaded"
|
||||
|
||||
export interface Activity {
|
||||
id: number;
|
||||
@@ -162,20 +264,68 @@ export const adminService = {
|
||||
|
||||
// Format activity message
|
||||
formatActivityMessage(activity: Activity): string {
|
||||
// Feature-flag toggles carry a `changed` diff in metadata. Render
|
||||
// it inline so the activity row says what actually flipped, not
|
||||
// just "feature_flags_updated".
|
||||
if (activity.type === 'feature_flags_updated') {
|
||||
return formatFeatureFlagsChanged(activity.metadata?.changed);
|
||||
}
|
||||
const md = activity.metadata || {};
|
||||
const messages: Record<string, string> = {
|
||||
'event_created': `New event created: ${activity.eventName || 'Unknown'}`,
|
||||
'photos_uploaded': `${activity.metadata.count || 0} photos uploaded to ${activity.eventName || 'Unknown'}`,
|
||||
'photos_uploaded': `${md.count || 0} photos uploaded to ${activity.eventName || 'Unknown'}`,
|
||||
'event_archived': `Event archived: ${activity.eventName || 'Unknown'}`,
|
||||
'event_published': `Event published: ${activity.eventName || md.event_name || 'Unknown'}`,
|
||||
'event_logo_uploaded': `Event logo uploaded for ${activity.eventName || 'Unknown'}`,
|
||||
'event_logo_removed': `Event logo removed for ${activity.eventName || 'Unknown'}`,
|
||||
'archive_restored': `Archive restored: ${activity.eventName || 'Unknown'}`,
|
||||
'archive_deleted': `Archive deleted: ${activity.metadata.event_name || 'Unknown'}`,
|
||||
'archive_deleted': `Archive deleted: ${md.event_name || 'Unknown'}`,
|
||||
'archive_downloaded': `Archive downloaded: ${activity.eventName || 'Unknown'}`,
|
||||
'email_config_updated': 'Email configuration updated',
|
||||
'email_template_updated': `Email template updated: ${activity.metadata.template_key || ''}`,
|
||||
'email_template_updated': `Email template updated: ${md.template_key || ''}`,
|
||||
'branding_updated': 'Branding settings updated',
|
||||
'theme_updated': 'Theme settings updated',
|
||||
'bulk_download': `${activity.metadata.photo_count || 0} photos downloaded from ${activity.eventName || 'Unknown'}`,
|
||||
'bulk_download': `${md.photo_count || 0} photos downloaded from ${activity.eventName || 'Unknown'}`,
|
||||
'bulk_delete_completed': `${md.deleted || md.count || 0} events deleted`,
|
||||
'gallery_password_entry': `Password entered for ${activity.eventName || 'Unknown'}`,
|
||||
'expiration_warning_viewed': `Expiration warning viewed for ${activity.eventName || 'Unknown'}`
|
||||
'expiration_warning_viewed': `Expiration warning viewed for ${activity.eventName || 'Unknown'}`,
|
||||
'photo_replaced': `Photo replaced in ${activity.eventName || 'Unknown'}`,
|
||||
'photo_uploaded': `Photo uploaded to ${activity.eventName || 'Unknown'}`,
|
||||
'category_hero_updated': `Category hero photo updated`,
|
||||
'public_site_reset_to_default': 'Public site reset to default',
|
||||
'cms_page_logo_uploaded': `CMS page logo uploaded: ${md.slug || ''}`,
|
||||
// Customer portal (#354).
|
||||
'customer_login': `Customer logged in: ${md.email || activity.actorName || ''}`,
|
||||
'customer_invitation_created': `Customer invitation sent to ${md.email || ''}`,
|
||||
'customer_invitation_accepted': `Customer accepted invitation: ${md.email || ''}`,
|
||||
'customer_invitation_cancelled': `Customer invitation cancelled: ${md.email || ''}`,
|
||||
'customer_password_reset_requested': `Password reset requested for customer ${md.email || ''}`,
|
||||
'customer_password_reset_applied': `Customer password reset by admin: ${md.email || ''}`,
|
||||
'customer_password_change': `Customer changed their password`,
|
||||
'customer_self_profile_update': `Customer updated their profile`,
|
||||
'customer_event_access': `Customer opened ${activity.eventName || md.event_slug || 'a gallery'}`,
|
||||
'customer_updated': `Customer account updated: ${md.email || ''}`,
|
||||
'customer_deactivated': `Customer account deactivated: ${md.email || ''}`,
|
||||
'customer_reactivated': `Customer account reactivated: ${md.email || ''}`,
|
||||
'customer_erased': `Customer account erased (GDPR): ${md.email || ''}`,
|
||||
// Admin user management (#350).
|
||||
'admin_invitation_created': `Admin invitation sent to ${md.email || ''}`,
|
||||
'admin_invitation_accepted': `Admin accepted invitation: ${md.email || md.username || ''}`,
|
||||
'admin_invitation_cancelled': `Admin invitation cancelled: ${md.email || ''}`,
|
||||
'admin_user_updated': `Admin user updated: ${md.username || md.email || ''}`,
|
||||
'admin_user_deactivated': `Admin user deactivated: ${md.username || md.email || ''}`,
|
||||
'admin_password_reset': `Admin password reset by another admin: ${md.username || md.email || ''}`,
|
||||
'admin_profile_updated': `Admin ${activity.actorName || ''} updated their profile`,
|
||||
// Webhooks (#327) + API tokens (#322) + event types.
|
||||
'webhook_created': `Webhook created: ${md.name || ''}`,
|
||||
'webhook_updated': `Webhook updated: ${md.name || ''}`,
|
||||
'webhook_deleted': `Webhook deleted: ${md.name || ''}`,
|
||||
'api_token_created': `API token created: ${md.name || ''}`,
|
||||
'api_token_revoked': `API token revoked: ${md.name || ''}`,
|
||||
'event_type_created': `Event type created: ${md.name || ''}`,
|
||||
'event_type_updated': `Event type updated: ${md.name || ''}`,
|
||||
'event_type_deleted': `Event type deleted: ${md.name || ''}`,
|
||||
'event_types_reordered': 'Event types reordered',
|
||||
};
|
||||
|
||||
return messages[activity.type] || activity.type;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../config/api';
|
||||
import i18n from '../i18n/config';
|
||||
import { formatFeatureFlagsChanged } from './admin.service';
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
@@ -148,14 +149,150 @@ export const notificationsService = {
|
||||
eventName: notification.metadata.event_name
|
||||
});
|
||||
case 'archive_restored':
|
||||
return t('admin.notificationMessages.archiveRestored', {
|
||||
eventName: notification.metadata.event_name
|
||||
return t('admin.notificationMessages.archiveRestored', {
|
||||
eventName: notification.metadata.event_name
|
||||
});
|
||||
|
||||
// ---- Feature flags --------------------------------------------------
|
||||
// Smart-formatted from metadata.changed so the row says what
|
||||
// actually flipped (e.g. "Customer Portal enabled") instead of
|
||||
// the raw activity type. Helper is shared with the Dashboard
|
||||
// recent-activity widget.
|
||||
case 'feature_flags_updated':
|
||||
return formatFeatureFlagsChanged(notification.metadata?.changed);
|
||||
|
||||
// ---- Customer portal (#354) -----------------------------------------
|
||||
case 'customer_login':
|
||||
return t('admin.notificationMessages.customerLogin', {
|
||||
email: notification.metadata.email || notification.actorName,
|
||||
});
|
||||
case 'customer_invitation_created':
|
||||
return t('admin.notificationMessages.customerInvitationCreated', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
case 'customer_invitation_accepted':
|
||||
return t('admin.notificationMessages.customerInvitationAccepted', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
case 'customer_invitation_cancelled':
|
||||
return t('admin.notificationMessages.customerInvitationCancelled', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
case 'customer_password_reset_requested':
|
||||
return t('admin.notificationMessages.customerPasswordResetRequested', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
case 'customer_password_reset_applied':
|
||||
return t('admin.notificationMessages.customerPasswordResetApplied', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
case 'customer_password_change':
|
||||
return t('admin.notificationMessages.customerPasswordChange', {
|
||||
email: notification.metadata.email || notification.actorName,
|
||||
});
|
||||
case 'customer_self_profile_update':
|
||||
return t('admin.notificationMessages.customerSelfProfileUpdate', {
|
||||
email: notification.metadata.email || notification.actorName,
|
||||
});
|
||||
case 'customer_event_access':
|
||||
return t('admin.notificationMessages.customerEventAccess', {
|
||||
email: notification.metadata.email || notification.actorName,
|
||||
eventName: notification.eventName || notification.metadata.event_slug || '',
|
||||
});
|
||||
case 'customer_updated':
|
||||
return t('admin.notificationMessages.customerUpdated', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
case 'customer_deactivated':
|
||||
return t('admin.notificationMessages.customerDeactivated', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
case 'customer_reactivated':
|
||||
return t('admin.notificationMessages.customerReactivated', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
case 'customer_erased':
|
||||
return t('admin.notificationMessages.customerErased', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
|
||||
// ---- Admin user management (#350) -----------------------------------
|
||||
case 'admin_invitation_created':
|
||||
return t('admin.notificationMessages.adminInvitationCreated', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
case 'admin_invitation_accepted':
|
||||
return t('admin.notificationMessages.adminInvitationAccepted', {
|
||||
email: notification.metadata.email || notification.metadata.username,
|
||||
});
|
||||
case 'admin_invitation_cancelled':
|
||||
return t('admin.notificationMessages.adminInvitationCancelled', {
|
||||
email: notification.metadata.email,
|
||||
});
|
||||
case 'admin_user_updated':
|
||||
return t('admin.notificationMessages.adminUserUpdated', {
|
||||
username: notification.metadata.username || notification.metadata.email,
|
||||
});
|
||||
case 'admin_user_deactivated':
|
||||
return t('admin.notificationMessages.adminUserDeactivated', {
|
||||
username: notification.metadata.username || notification.metadata.email,
|
||||
});
|
||||
case 'admin_password_reset':
|
||||
return t('admin.notificationMessages.adminPasswordResetByAdmin', {
|
||||
username: notification.metadata.username || notification.metadata.email,
|
||||
});
|
||||
|
||||
// ---- Webhooks (#327) + API tokens (#322) + event types --------------
|
||||
case 'webhook_created':
|
||||
return t('admin.notificationMessages.webhookCreated', { name: notification.metadata.name });
|
||||
case 'webhook_updated':
|
||||
return t('admin.notificationMessages.webhookUpdated', { name: notification.metadata.name });
|
||||
case 'webhook_deleted':
|
||||
return t('admin.notificationMessages.webhookDeleted', { name: notification.metadata.name });
|
||||
case 'api_token_created':
|
||||
return t('admin.notificationMessages.apiTokenCreated', { name: notification.metadata.name });
|
||||
case 'api_token_revoked':
|
||||
return t('admin.notificationMessages.apiTokenRevoked', { name: notification.metadata.name });
|
||||
case 'event_type_created':
|
||||
return t('admin.notificationMessages.eventTypeCreated', { name: notification.metadata.name });
|
||||
case 'event_type_updated':
|
||||
return t('admin.notificationMessages.eventTypeUpdated', { name: notification.metadata.name });
|
||||
case 'event_type_deleted':
|
||||
return t('admin.notificationMessages.eventTypeDeleted', { name: notification.metadata.name });
|
||||
case 'event_types_reordered':
|
||||
return t('admin.notificationMessages.eventTypesReordered');
|
||||
|
||||
// ---- Other recent surfaces ------------------------------------------
|
||||
case 'event_published':
|
||||
return t('admin.notificationMessages.eventPublished', {
|
||||
eventName: notification.eventName || notification.metadata.event_name,
|
||||
});
|
||||
case 'event_logo_uploaded':
|
||||
return t('admin.notificationMessages.eventLogoUploaded', { eventName: notification.eventName });
|
||||
case 'event_logo_removed':
|
||||
return t('admin.notificationMessages.eventLogoRemoved', { eventName: notification.eventName });
|
||||
case 'bulk_delete_completed':
|
||||
return t('admin.notificationMessages.bulkDeleteCompleted', {
|
||||
count: notification.metadata.deleted || notification.metadata.count || 0,
|
||||
});
|
||||
case 'photo_replaced':
|
||||
return t('admin.notificationMessages.photoReplaced', { eventName: notification.eventName });
|
||||
case 'photo_uploaded':
|
||||
return t('admin.notificationMessages.photoUploaded', { eventName: notification.eventName });
|
||||
case 'category_hero_updated':
|
||||
return t('admin.notificationMessages.categoryHeroUpdated');
|
||||
case 'public_site_reset_to_default':
|
||||
return t('admin.notificationMessages.publicSiteResetToDefault');
|
||||
case 'cms_page_logo_uploaded':
|
||||
return t('admin.notificationMessages.cmsPageLogoUploaded', {
|
||||
slug: notification.metadata.slug,
|
||||
});
|
||||
|
||||
default:
|
||||
// Log unknown notification types for debugging
|
||||
console.warn('Unknown notification type:', notification.type, notification);
|
||||
return notification.metadata.message || t('admin.notificationMessages.systemActivity', {
|
||||
type: notification.type.replace(/_/g, ' ')
|
||||
return notification.metadata.message || t('admin.notificationMessages.systemActivity', {
|
||||
type: notification.type.replace(/_/g, ' ')
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -211,6 +348,68 @@ export const notificationsService = {
|
||||
case 'archive_deleted':
|
||||
case 'archive_restored':
|
||||
return { icon: 'Archive', color: 'text-blue-600' };
|
||||
|
||||
// Feature flag toggles + customer / admin / webhook surfaces. Icon
|
||||
// choices favour the tone of the action (settings / users / network)
|
||||
// over per-event-type cleverness.
|
||||
case 'feature_flags_updated':
|
||||
return { icon: 'ToggleRight', color: 'text-amber-600' };
|
||||
case 'customer_login':
|
||||
case 'customer_event_access':
|
||||
case 'customer_self_profile_update':
|
||||
case 'customer_password_change':
|
||||
return { icon: 'User', color: 'text-blue-600' };
|
||||
case 'customer_invitation_created':
|
||||
case 'customer_invitation_accepted':
|
||||
case 'customer_invitation_cancelled':
|
||||
return { icon: 'Mail', color: 'text-teal-600' };
|
||||
case 'customer_password_reset_requested':
|
||||
case 'customer_password_reset_applied':
|
||||
return { icon: 'Lock', color: 'text-indigo-600' };
|
||||
case 'customer_updated':
|
||||
case 'customer_deactivated':
|
||||
case 'customer_reactivated':
|
||||
return { icon: 'UserCog', color: 'text-gray-600' };
|
||||
case 'customer_erased':
|
||||
return { icon: 'Trash2', color: 'text-red-600' };
|
||||
case 'admin_invitation_created':
|
||||
case 'admin_invitation_accepted':
|
||||
case 'admin_invitation_cancelled':
|
||||
return { icon: 'Mail', color: 'text-teal-600' };
|
||||
case 'admin_user_updated':
|
||||
case 'admin_user_deactivated':
|
||||
return { icon: 'UserCog', color: 'text-gray-600' };
|
||||
case 'admin_password_reset':
|
||||
return { icon: 'Lock', color: 'text-indigo-600' };
|
||||
case 'webhook_created':
|
||||
case 'webhook_updated':
|
||||
case 'webhook_deleted':
|
||||
return { icon: 'Webhook', color: 'text-purple-600' };
|
||||
case 'api_token_created':
|
||||
case 'api_token_revoked':
|
||||
return { icon: 'Key', color: 'text-orange-600' };
|
||||
case 'event_type_created':
|
||||
case 'event_type_updated':
|
||||
case 'event_type_deleted':
|
||||
case 'event_types_reordered':
|
||||
return { icon: 'Tag', color: 'text-violet-600' };
|
||||
case 'event_published':
|
||||
return { icon: 'CheckCircle', color: 'text-green-600' };
|
||||
case 'event_logo_uploaded':
|
||||
case 'event_logo_removed':
|
||||
return { icon: 'Image', color: 'text-pink-600' };
|
||||
case 'bulk_delete_completed':
|
||||
return { icon: 'Trash2', color: 'text-red-600' };
|
||||
case 'photo_replaced':
|
||||
case 'photo_uploaded':
|
||||
return { icon: 'Image', color: 'text-purple-600' };
|
||||
case 'category_hero_updated':
|
||||
return { icon: 'Folder', color: 'text-indigo-600' };
|
||||
case 'public_site_reset_to_default':
|
||||
return { icon: 'Globe', color: 'text-gray-600' };
|
||||
case 'cms_page_logo_uploaded':
|
||||
return { icon: 'FileText', color: 'text-green-600' };
|
||||
|
||||
default:
|
||||
return { icon: 'Bell', color: 'text-gray-600' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user