changemaker.lite/api/src/services/gancio-settings-sync.service.ts

467 lines
21 KiB
TypeScript

import { gancioClient } from './gancio.client';
import { siteSettingsService } from '../modules/settings/settings.service';
import { env } from '../config/env';
import { logger } from '../utils/logger';
// CML settings fields that trigger a Gancio sync when changed
const GANCIO_RELEVANT_FIELDS = [
'organizationName',
'organizationShortName',
'publicColorPrimary',
'publicColorBgBase',
'publicColorBgContainer',
'publicHeaderGradient',
'footerText',
'enableEvents',
// Feature flags that affect the injected nav bar
'enableInfluence',
'enableMap',
'enableMediaFeatures',
'enablePayments',
'navConfig',
] as const;
interface GancioColorPalette {
primary?: string;
error?: string;
info?: string;
success?: string;
warning?: string;
}
/**
* Generates custom CSS for Gancio to match CML's dark theme.
* Targets Vuetify component classes used in Gancio's UI.
*/
function buildCustomCss(settings: {
publicColorBgBase?: string | null;
publicColorBgContainer?: string | null;
publicColorPrimary?: string | null;
publicHeaderGradient?: string | null;
}): string {
const lines: string[] = ['/* Auto-synced from Changemaker Lite */'];
if (settings.publicColorBgBase) {
lines.push(`.v-application { background-color: ${settings.publicColorBgBase} !important; }`);
lines.push(`.v-main { background-color: ${settings.publicColorBgBase} !important; }`);
}
if (settings.publicColorBgContainer) {
lines.push(`.v-card { background-color: ${settings.publicColorBgContainer} !important; }`);
lines.push(`.v-dialog .v-card { background-color: ${settings.publicColorBgContainer} !important; }`);
}
if (settings.publicHeaderGradient) {
lines.push(`.v-app-bar { background: ${settings.publicHeaderGradient} !important; }`);
} else if (settings.publicColorPrimary) {
lines.push(`.v-app-bar { background-color: ${settings.publicColorPrimary} !important; }`);
}
// Push Gancio content down to make room for the injected CML nav bar (CSS fallback)
lines.push(`.v-application--wrap, .v-application .v-main { padding-top: 56px !important; }`);
return lines.join('\n');
}
/**
* Compact inline SVG icons matching Ant Design's outlined style.
* Stroke-based, 24x24 viewBox, rendered at 1em (14px in the nav context).
* Using fill="none" stroke="currentColor" so they inherit link color + hover transitions.
*/
const NAV_ICONS: Record<string, string> = {
Home: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
Campaigns: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
Map: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>',
Shifts: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
Events: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
Gallery: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>',
Donate: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
Admin: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
Website: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
Docs: '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
};
/**
* Generates a self-contained JS snippet that injects a CML navigation bar
* into Gancio's page. Uses Gancio's `custom_js` setting.
*
* The bar mirrors PublicLayout's header: same gradient, colors, nav links,
* inline SVG icons, and feature-flag-aware conditional rendering.
*/
interface NavConfigItem {
id: string;
label: string;
path: string;
icon: string;
enabled: boolean;
order: number;
type: 'builtin' | 'custom' | 'group';
featureFlag?: string;
external?: boolean;
children?: NavConfigItem[];
}
/** Map navConfig icon IDs to the SVG NAV_ICONS keys */
const ICON_ID_TO_KEY: Record<string, string> = {
HomeOutlined: 'Home',
SendOutlined: 'Campaigns',
EnvironmentOutlined: 'Map',
CalendarOutlined: 'Shifts',
ScheduleOutlined: 'Shifts',
BarChartOutlined: 'Events',
PlayCircleOutlined: 'Gallery',
HeartOutlined: 'Donate',
DollarOutlined: 'Donate',
ShoppingOutlined: 'Donate',
GlobalOutlined: 'Website',
BookOutlined: 'Docs',
TagOutlined: 'Events',
VideoCameraOutlined: 'Events',
FileTextOutlined: 'Docs',
TrophyOutlined: 'Home',
};
function buildCustomJs(settings: {
organizationName?: string | null;
publicHeaderGradient?: string | null;
publicColorPrimary?: string | null;
enableInfluence?: boolean | null;
enableMap?: boolean | null;
enableMediaFeatures?: boolean | null;
enablePayments?: boolean | null;
enableEvents?: boolean | null;
enableMeetingPlanner?: boolean | null;
enableTicketedEvents?: boolean | null;
enableSocial?: boolean | null;
enableMeet?: boolean | null;
enableLandingPages?: boolean | null;
navConfig?: { items: NavConfigItem[] } | null;
}, appUrl: string, homeUrl: string): string {
const orgName = settings.organizationName || 'Changemaker Lite';
const gradient = settings.publicHeaderGradient || 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)';
// Strip trailing slash from URLs for consistent link construction
const baseUrl = appUrl.replace(/\/+$/, '');
const home = homeUrl.replace(/\/+$/, '');
// Build nav links array from navConfig if available, else fall back to feature flags
const links: Array<{ href: string; label: string; icon: string; active?: boolean }> = [];
if (settings.navConfig?.items) {
const featureFlagMap: Record<string, boolean | null | undefined> = {
enableInfluence: settings.enableInfluence,
enableMap: settings.enableMap,
enableMediaFeatures: settings.enableMediaFeatures,
enablePayments: settings.enablePayments,
enableEvents: settings.enableEvents,
enableMeetingPlanner: settings.enableMeetingPlanner,
enableTicketedEvents: settings.enableTicketedEvents,
enableSocial: settings.enableSocial,
enableMeet: settings.enableMeet,
enableLandingPages: settings.enableLandingPages,
};
// Flatten groups: Gancio uses a flat HTML nav bar, no dropdown support
const flatItems: NavConfigItem[] = [];
for (const item of settings.navConfig.items) {
if (item.type === 'group' && item.children) {
for (const child of item.children) flatItems.push(child);
} else {
flatItems.push(item);
}
}
const sorted = flatItems
.filter(item => item.enabled)
.filter(item => !item.featureFlag || featureFlagMap[item.featureFlag] !== false)
.sort((a, b) => a.order - b.order);
for (const item of sorted) {
// Resolve $token paths first
if (item.path === '$landing') {
links.push({ href: home, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Website' });
} else if (item.path === '$docs') {
links.push({ href: `${home}/docs/`, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Docs' });
} else if (item.id === 'events') {
// Events is the current page on Gancio, mark active
links.push({ href: '#', label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Events', active: true });
} else if (item.external && item.id === 'home') {
links.push({ href: home, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || 'Home' });
} else if (item.external) {
links.push({ href: item.path, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || item.label });
} else {
links.push({ href: `${baseUrl}${item.path}`, label: item.label, icon: ICON_ID_TO_KEY[item.icon] || item.label });
}
}
} else {
// Legacy fallback: build from feature flags
links.push({ href: home, label: 'Home', icon: 'Home' });
if (settings.enableInfluence !== false) {
links.push({ href: `${baseUrl}/campaigns`, label: 'Campaigns', icon: 'Campaigns' });
}
if (settings.enableMap !== false) {
links.push({ href: `${baseUrl}/map`, label: 'Map', icon: 'Map' });
links.push({ href: `${baseUrl}/shifts`, label: 'Shifts', icon: 'Shifts' });
}
links.push({ href: '#', label: 'Events', icon: 'Events', active: true });
if (settings.enableMediaFeatures !== false) {
links.push({ href: `${baseUrl}/gallery`, label: 'Gallery', icon: 'Gallery' });
}
if (settings.enablePayments === true) {
links.push({ href: `${baseUrl}/donate`, label: 'Donate', icon: 'Donate' });
}
}
// Always add Admin link — the admin page handles its own auth guard
links.push({ href: `${baseUrl}/app`, label: 'Admin', icon: 'Admin' });
// Serialize links for embedding in JS (icon key is a string, resolved at runtime)
const linksJson = JSON.stringify(links);
// Build the icons object for the generated JS (keyed by label)
const iconsUsed = [...new Set(links.map((l) => l.icon))];
const iconsObj = iconsUsed
.map((key) => `${key}:'${NAV_ICONS[key]?.replace(/'/g, "\\'") ?? ''}'`)
.join(',');
// Safe-escaped org name for embedding in JS string literals
const orgNameEscaped = orgName.replace(/'/g, "\\'").replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `(function(){
if(document.getElementById('cml-nav-bar'))return;
var icons={${iconsObj}};
var bar=document.createElement('div');
bar.id='cml-nav-bar';
bar.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9999;height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:${gradient};font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;box-sizing:border-box;';
var brand=document.createElement('a');
brand.href='${baseUrl}/campaigns';
brand.style.cssText='color:#fff;text-decoration:none;font-size:18px;font-weight:600;display:flex;align-items:center;gap:8px;white-space:nowrap;';
brand.textContent='${orgNameEscaped}';
bar.appendChild(brand);
var nav=document.createElement('div');
nav.className='cml-nav-links';
nav.style.cssText='display:flex;align-items:center;gap:16px;';
var links=${linksJson};
links.forEach(function(l){
var a=document.createElement('a');
a.href=l.href;
a.style.cssText='color:'+(l.active?'#fff':'rgba(255,255,255,0.85)')+';text-decoration:none;font-size:14px;font-weight:'+(l.active?'600':'400')+';border-bottom:'+(l.active?'2px solid #fff':'2px solid transparent')+';padding-bottom:2px;transition:color 0.2s;white-space:nowrap;display:inline-flex;align-items:center;gap:6px;';
a.innerHTML=(icons[l.icon]||'')+'<span>'+l.label+'</span>';
if(!l.active){
a.addEventListener('mouseenter',function(){this.style.color='#fff';});
a.addEventListener('mouseleave',function(){this.style.color='rgba(255,255,255,0.85)';});
}
nav.appendChild(a);
});
bar.appendChild(nav);
var mobileBack=document.createElement('a');
mobileBack.href='${baseUrl}/campaigns';
mobileBack.className='cml-nav-mobile-back';
mobileBack.style.cssText='display:none;color:#fff;text-decoration:none;font-size:14px;white-space:nowrap;';
mobileBack.textContent='\\u2190 Back to ${orgNameEscaped}';
bar.appendChild(mobileBack);
var style=document.createElement('style');
style.textContent='.v-application--wrap,.v-application .v-main{padding-top:56px!important;}@media(max-width:768px){.cml-nav-links{display:none!important;}.cml-nav-mobile-back{display:block!important;}}';
document.head.appendChild(style);
document.body.insertBefore(bar,document.body.firstChild);
})();`;
}
class GancioSettingsSyncService {
private syncInProgress = false;
/**
* Quick check: does the update payload contain any Gancio-relevant fields?
*/
hasGancioChanges(payload: Record<string, unknown>): boolean {
return GANCIO_RELEVANT_FIELDS.some((f) => f in payload);
}
/**
* Full sync: reads CML settings, reads existing Gancio settings (to preserve
* non-primary colors), then pushes all mapped settings to Gancio.
* Used at API startup.
*/
async syncAll(): Promise<void> {
if (!gancioClient.enabled) return;
if (this.syncInProgress) {
logger.debug('Gancio settings sync already in progress, skipping');
return;
}
this.syncInProgress = true;
try {
const settings = await siteSettingsService.get();
// Guard: check DB-level feature flag
if (!settings.enableEvents) {
logger.debug('Gancio sync skipped: enableEvents is false');
return;
}
// Read existing Gancio settings to preserve non-primary color values
const gancioSettings = await gancioClient.getSettings();
const promises: Promise<boolean>[] = [];
// Title & description
if (settings.organizationName) {
promises.push(gancioClient.setSetting('title', settings.organizationName));
promises.push(gancioClient.setSetting('description', `${settings.organizationName} Events`));
}
// Dark theme
promises.push(gancioClient.setSetting('theme.is_dark', true));
// Color palettes — read-then-merge to preserve error/info/success/warning
if (settings.publicColorPrimary) {
const existingDark = (gancioSettings?.dark_colors as GancioColorPalette) || {};
const existingLight = (gancioSettings?.light_colors as GancioColorPalette) || {};
promises.push(gancioClient.setSetting('dark_colors', {
...existingDark,
primary: settings.publicColorPrimary,
}));
promises.push(gancioClient.setSetting('light_colors', {
...existingLight,
primary: settings.publicColorPrimary,
}));
}
// Footer links
if (settings.footerText) {
const adminUrl = env.ADMIN_URL;
promises.push(gancioClient.setSetting('footerLinks', [
{ label: settings.footerText, href: adminUrl },
]));
}
// Custom CSS for deeper theming
const css = buildCustomCss(settings);
promises.push(gancioClient.setSetting('custom_css', css));
// Custom JS — inject CML navigation bar into Gancio
const appUrl = env.ADMIN_URL;
const homeUrl = `https://${env.DOMAIN}`;
const js = buildCustomJs(settings as any, appUrl, homeUrl);
promises.push(gancioClient.setSetting('custom_js', js));
const results = await Promise.allSettled(promises);
const succeeded = results.filter((r) => r.status === 'fulfilled' && r.value).length;
const failed = results.length - succeeded;
if (failed > 0) {
logger.warn(`Gancio settings sync: ${succeeded} succeeded, ${failed} failed`);
} else {
logger.info(`Gancio settings sync: synced ${succeeded} settings`);
}
} catch (err) {
logger.warn('Gancio settings sync failed:', err instanceof Error ? err.message : err);
} finally {
this.syncInProgress = false;
}
}
/**
* Partial sync: only pushes Gancio settings for fields that actually changed.
* Used from the PUT /api/settings handler.
*/
async syncChanged(changedFields: Record<string, unknown>): Promise<void> {
if (!gancioClient.enabled) return;
try {
// If enableEvents was just turned off, skip sync
if (changedFields.enableEvents === false) {
logger.debug('Gancio sync skipped: enableEvents set to false');
return;
}
// If enableEvents was just turned on, do a full sync
if (changedFields.enableEvents === true) {
await this.syncAll();
return;
}
// Check DB-level feature flag (may not be in changedFields)
const settings = await siteSettingsService.get();
if (!settings.enableEvents) {
logger.debug('Gancio sync skipped: enableEvents is false');
return;
}
const promises: Promise<boolean>[] = [];
// Organization name → title + description
if ('organizationName' in changedFields && changedFields.organizationName) {
const name = changedFields.organizationName as string;
promises.push(gancioClient.setSetting('title', name));
promises.push(gancioClient.setSetting('description', `${name} Events`));
}
// Primary color → dark_colors + light_colors (read-then-merge)
if ('publicColorPrimary' in changedFields && changedFields.publicColorPrimary) {
const gancioSettings = await gancioClient.getSettings();
const existingDark = (gancioSettings?.dark_colors as GancioColorPalette) || {};
const existingLight = (gancioSettings?.light_colors as GancioColorPalette) || {};
promises.push(gancioClient.setSetting('dark_colors', {
...existingDark,
primary: changedFields.publicColorPrimary,
}));
promises.push(gancioClient.setSetting('light_colors', {
...existingLight,
primary: changedFields.publicColorPrimary,
}));
}
// Footer text → footerLinks
if ('footerText' in changedFields) {
const adminUrl = env.ADMIN_URL;
if (changedFields.footerText) {
promises.push(gancioClient.setSetting('footerLinks', [
{ label: changedFields.footerText as string, href: adminUrl },
]));
} else {
promises.push(gancioClient.setSetting('footerLinks', []));
}
}
// Theme-related CSS fields — rebuild CSS from full settings (not just changed fields)
const cssFields = ['publicColorBgBase', 'publicColorBgContainer', 'publicHeaderGradient', 'publicColorPrimary'];
if (cssFields.some((f) => f in changedFields)) {
const css = buildCustomCss(settings);
promises.push(gancioClient.setSetting('custom_css', css));
}
// Nav bar JS — rebuild when org name, feature flags, header gradient, or navConfig change
const jsFields = [
'organizationName', 'publicHeaderGradient', 'publicColorPrimary',
'enableInfluence', 'enableMap', 'enableMediaFeatures', 'enablePayments',
'navConfig',
];
if (jsFields.some((f) => f in changedFields)) {
const appUrl = env.ADMIN_URL;
const homeUrl = `https://${env.DOMAIN}`;
const js = buildCustomJs(settings as any, appUrl, homeUrl);
promises.push(gancioClient.setSetting('custom_js', js));
}
if (promises.length === 0) return;
const results = await Promise.allSettled(promises);
const succeeded = results.filter((r) => r.status === 'fulfilled' && r.value).length;
const failed = results.length - succeeded;
if (failed > 0) {
logger.warn(`Gancio partial sync: ${succeeded}/${results.length} succeeded`);
} else {
logger.debug(`Gancio partial sync: ${succeeded} settings updated`);
}
} catch (err) {
logger.warn('Gancio partial sync failed:', err instanceof Error ? err.message : err);
}
}
}
export const gancioSettingsSyncService = new GancioSettingsSyncService();