Add admin-configurable branding (colors, logo, favicon) and generic default fallbacks

Site Settings -> Branding now supports a Primary/Secondary/Accent brand color
system applied site-wide (buttons, nav, hover states, links) and to outgoing
email header/CTA colors, plus a favicon upload alongside the existing logo
upload, a live preview panel (website/email x desktop/mobile), and
logo-based color suggestions. The setup wizard's Branding step got the same
treatment. Fixes two related bugs found along the way: the setup wizard's
logo/favicon upload was missing its auth token, and a static favicon.ico in
Next's special app/ convention path was silently overriding the dynamic one.

Also replaces every "Hope Events"/"Hope Family Church" default (org name,
email subjects, WhatsApp messages, report metadata, API docs) with a neutral
"Cross Code" placeholder, and the optional legal settings (operator name, IO
details, website URL, effective date) with obviously-generic placeholders
instead of defaulting to real personal/organisational details -- since this
platform is deployed for multiple organisations. Adds SETTINGS.md documenting
every setting's default behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 14:49:51 +02:00
co-authored by Claude Sonnet 5
parent 2d99b7cafe
commit 86af7093ac
33 changed files with 1174 additions and 198 deletions
+2
View File
@@ -452,6 +452,8 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/settings` | public | Public settings (org name, tagline, colour, logo, legal keys) |
+4 -3
View File
@@ -64,8 +64,9 @@ const MAPPINGS = [
{ key: 'org_tagline', envVars: ['ORG_TAGLINE'] },
{ key: 'org_email', envVars: ['EMAIL_FROM', 'EMAIL_USER', 'SMTP_FROM', 'SMTP_USER'] },
// Branding
{ key: 'accent_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
// Branding — these env vars historically fed the single "brand color" concept,
// which is now the Primary color in the 3-color Primary/Secondary/Accent system.
{ key: 'primary_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
// Notifications
{ key: 'reg_notification_emails',envVars: ['REGISTRATIONS_EMAIL'] },
@@ -88,7 +89,7 @@ async function main() {
const prisma = new PrismaClient();
console.log(`\n${'─'.repeat(60)}`);
console.log(' Hope Events — .env → DB settings migration');
console.log(' Settings migration: .env → DB');
if (DRY_RUN) console.log(' MODE: DRY RUN (no changes will be made)');
if (FORCE) console.log(' MODE: FORCE (will overwrite existing DB values)');
console.log(`${'─'.repeat(60)}\n`);
+2 -1
View File
@@ -250,7 +250,8 @@ async function renderReportPdfToFile(payload) {
async function buildReportWorkbook(payload) {
const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {};
const wb = new ExcelJS.Workbook();
wb.creator = 'Hope Family Church Events';
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
wb.creator = `${orgName} Events`;
wb.created = new Date();
const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report';
const ws = wb.addWorksheet(sheetName);
+12 -5
View File
@@ -8,7 +8,13 @@ const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
// Keys safe to return without auth — includes legal keys needed by public legal pages
const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
'accent_color', 'logo_url', 'setup_complete', 'app_base_url',
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
// system (accent_color's meaning was repurposed from "the one brand color" to
// "tertiary accent color"; primary_color falls back to accent_color's legacy
// value wherever it's consumed, so older data still resolves sensibly until
// the admin re-saves the Branding tab).
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
'setup_complete', 'app_base_url',
// Legal pages
'legal_operator_name', 'legal_io_name', 'legal_io_email',
'legal_website_url', 'legal_effective_date',
@@ -293,12 +299,13 @@ const testSmtp = async (req, res) => {
// Send a real test email to the authenticated user so there's visible proof
const adminEmail = req.user?.email;
if (adminEmail) {
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
await transporter.sendMail({
from: from || user || 'no-reply@hope-events.local',
from: from || user || 'no-reply@crosscode.local',
to: adminEmail,
subject: 'SMTP test — Hope Events',
text: `This is a test email sent from the Hope Events admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
html: `<p>This is a test email sent from the <strong>Hope Events</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
subject: `SMTP test — ${orgName}`,
text: `This is a test email sent from the ${orgName} admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
html: `<p>This is a test email sent from the <strong>${orgName}</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
});
}
@@ -70,6 +70,34 @@ const uploadLogo = multer({
}
});
// Favicon storage (same subfolder as the logo — both are "branding" assets)
const faviconStorage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding');
try {
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
} catch (error) {
cb(new Error(`Cannot access upload directory: ${error.message}`));
}
},
filename: function (req, file, cb) {
cb(null, `favicon-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
}
});
const uploadFavicon = multer({
storage: faviconStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
fileFilter: function (req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.ico', '.png', '.svg'].includes(ext)) {
return cb(new Error('Only .ico, .png, or .svg files are allowed for the favicon'), false);
}
cb(null, true);
}
});
// Controller function
const uploadEventImage = (req, res) => {
// Check for multer errors which would be passed in req.multerError
@@ -95,9 +123,21 @@ const uploadLogoImage = (req, res) => {
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
};
const uploadFaviconImage = (req, res) => {
if (req.multerError) {
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
}
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
};
module.exports = {
upload,
uploadEventImage,
uploadLogo,
uploadLogoImage,
uploadFavicon,
uploadFaviconImage,
};
+14 -8
View File
@@ -3,10 +3,17 @@ const { generateToken, hashPassword, comparePassword } = require('../config/auth
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const { getSettingSync } = require('../utils/settingsCache');
const axios = require('axios');
// ─── Helpers ────────────────────────────────────────────────────────────────
// Org name for email subjects — falls back to Cross Code (the platform vendor)
// rather than any specific customer's name when unconfigured.
function getOrgName() {
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
}
// Resolve a client IP from the request (works behind proxies)
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
@@ -41,7 +48,7 @@ async function sendLoginNotification(user, req) {
const { buildWALogin } = require('../utils/waMessages');
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
// Security: always email; also WhatsApp if preferred
await sendMail({ to: user.email, subject: 'New login to your Hope Events account', ...content });
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
const { waText } = require('../utils/notify');
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
} catch (e) {
@@ -63,7 +70,7 @@ async function sendWelcomeEmail(user) {
const content = buildWelcomeEmail({ name: user.name, events });
const { shouldEmail, waText } = require('../utils/notify');
// Welcome is always sent via email; also via WhatsApp if preferred
await sendMail({ to: user.email, subject: 'Welcome to Hope Events!', ...content });
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
} catch (e) {
console.warn('[welcome email] Failed:', e?.message || e);
@@ -185,7 +192,7 @@ const loginUser = async (req, res) => {
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
sendMail({ to: user.email, subject: 'Activate your Hope Events account', ...content })
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
} catch (e) {
console.warn('[activation token] Failed to create activation token:', e?.message || e);
@@ -207,7 +214,7 @@ const loginUser = async (req, res) => {
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events');
const orgName = getOrgName();
const waMessage = [
`🔓 *Activate your ${orgName} account*`,
'',
@@ -394,10 +401,9 @@ const updateUserProfile = async (req, res) => {
// If the password was changed, send a security alert email (fire-and-forget)
if (newHashedPassword) {
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const { getSettingSync } = require('../utils/settingsCache');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
sendMail({ to: updatedUser.email, subject: 'Your Hope Events password was changed', ...content })
sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify');
waText(updatedUser, content.text).catch(() => {});
@@ -903,7 +909,7 @@ const closeAccount = async (req, res) => {
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
// WhatsApp while we still have phone (send before data wipe completes in-flight)
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
}
@@ -924,7 +930,7 @@ const closeAccount = async (req, res) => {
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: false });
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {});
}
return res.json({ message: 'Your account has been deactivated.' });
@@ -195,13 +195,14 @@ const handleWebhook = async (req, res) => {
const { getSettingSync } = require('../utils/settingsCache');
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`;
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
await sendMail({
to: adminEmail,
subject: 'WhatsApp session is down — action required',
text: `The Hope Events WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
html: `<p>The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
text: `The ${orgName} WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
html: `<p>The ${orgName} WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
}).catch(() => {});
}
} catch (e) {
+16 -16
View File
@@ -244,8 +244,8 @@ app.get('/', async (req, res) => {
? `<span class="badge badge-warn">testing</span>`
: `<span class="badge badge-warn">development</span>`;
const html = pageShell('Hope Events API — Status', '#2563eb', `
<h1>Hope Events API</h1>
const html = pageShell('Cross Code Events API — Status', '#2563eb', `
<h1>Cross Code Events API</h1>
<p class="subtitle">v${API_VERSION} &mdash; ${now}</p>
<div class="stat-grid">
@@ -533,7 +533,7 @@ app.get('/docs', async (req, res) => {
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.hopehenley.co.za/payment/success', cancelUrl:'https://events.hopehenley.co.za/payment/cancel', failureUrl:'https://events.hopehenley.co.za/payment/failure' }},
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
responses:[
{ status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }},
{ status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }},
@@ -750,7 +750,7 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Saved', body:{ message:'WhatsApp config saved.' }}]},
{ method:'GET', path:'/api/whatsapp/status', auth:'admin', desc:'Get the current WAWP session status',
responses:[
{ status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Hope Events' }},
{ status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Cross Code' }},
{ status:200, desc:'Not connected', body:{ status:'close' }},
]},
{ method:'GET', path:'/api/whatsapp/qr', auth:'admin', desc:'Get a QR code image/string to link a WhatsApp account',
@@ -773,14 +773,14 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Settings', base: '/api/settings', endpoints: [
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, accent colour, logo URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Hope Family Church', org_tagline:'Where everyone belongs', accent_color:'#2563eb', logo_url:'/uploads/logo.png' }}]},
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
{ method:'GET', path:'/api/settings/all', auth:'admin', desc:'All settings including SMTP config. smtp_pass is returned masked (••••••••); smtp_user is returned decrypted.',
responses:[{ status:200, desc:'Success', body:[{ key:'smtp_host', value:'smtp.example.com' },{ key:'smtp_user', value:'user@example.com' },{ key:'smtp_pass', value:'••••••••' }]}]},
{ method:'GET', path:'/api/settings/needs-setup', auth:'public', desc:'Returns true until the setup wizard has been completed (setup_complete flag is set).',
responses:[{ status:200, desc:'Success', body:{ needsSetup:true }}]},
{ method:'PUT', path:'/api/settings', auth:'admin', desc:'Upsert one or more settings. smtp_user and smtp_pass are AES-256-GCM encrypted before storage. Sending •••••••• for smtp_pass is a no-op.',
request:{ body:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }},
request:{ body:{ org_name:'Cross Code', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }},
responses:[{ status:200, desc:'Saved', body:{ message:'Settings saved.' }}]},
{ method:'POST', path:'/api/settings/test-smtp', auth:'admin (or setup token)', desc:'Test the SMTP connection with provided credentials. On success, sends a real test email to the authenticated admin and returns a friendly message. On failure, returns a human-readable message plus a raw field containing the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.',
request:{ body:{ host:'smtp.gmail.com', port:587, secure:false, user:'me@gmail.com', pass:'app-password', from:'me@gmail.com' }},
@@ -798,7 +798,7 @@ app.get('/docs', async (req, res) => {
{ status:400, desc:'Already exists', body:{ message:'Setup already completed — users exist.' }},
]},
{ method:'POST', path:'/api/setup', auth:'setup token or admin', desc:'Final step of the setup wizard. Saves initial site settings. Requires the JWT returned by POST /api/setup/register.',
request:{ body:{ settings:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}},
request:{ body:{ settings:{ org_name:'Cross Code', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}},
responses:[{ status:200, desc:'Setup complete', body:{ message:'Setup complete.' }}]},
]},
];
@@ -964,12 +964,12 @@ app.get('/docs', async (req, res) => {
{ trigger: 'Scheduled — every day at 07:00 local server time, once per active event that has gone live and has not yet started', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Daily summary: {event} — {date}', content: 'Stat tiles (registrations, paid, awaiting payment, revenue), full registrations table with balances, full payments & donations table.' },
]},
{ category: 'Account & security', items: [
{ trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to Hope Events!', content: 'Welcome message plus a list of upcoming events.' },
{ trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your Hope Events account', content: 'One-time activation link; expires after 24 hours.' },
{ trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your Hope Events account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' },
{ trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your Hope Events password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' },
{ trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to {org}!', content: 'Welcome message plus a list of upcoming events.' },
{ trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your {org} account', content: 'One-time activation link; expires after 24 hours.' },
{ trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your {org} account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' },
{ trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your {org} password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' },
{ trigger: 'Forgot-password request', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Reset your password', content: 'Password reset link.' },
{ trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your Hope Events account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' },
{ trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your {org} account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' },
]},
{ category: 'Admin-triggered broadcasts', items: [
{ trigger: 'Bulk email to event attendees, or an ad-hoc email broadcast — sent immediately or on a schedule', channels: ['Email'], recipients: 'Selected attendees / users / ad-hoc addresses chosen by the sender', subject: 'Admin-authored', content: 'Free-form subject & body written by the sender, supporting {{name}}, {{event.title}}, {{event.link}} placeholders. Used for reminders, thank-yous, promos, and multi-step automations.' },
@@ -1019,7 +1019,7 @@ app.get('/docs', async (req, res) => {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hope Events — API Docs</title>
<title>Cross Code Events — API Docs</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
@@ -1043,7 +1043,7 @@ app.get('/docs', async (req, res) => {
<body>
<div class="wrap">
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:6px">
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">Hope Events — API Reference</h1>
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">Cross Code Events — API Reference</h1>
<a href="/" style="font-size:.82rem;color:#6b7280">&#8592; Status page</a>
</div>
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
@@ -1070,7 +1070,7 @@ app.get('/docs', async (req, res) => {
${notificationsHtml}
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
Hope Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
Cross Code Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
</p>
</div>
<script>
+18 -7
View File
@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const { upload, uploadEventImage, uploadLogo, uploadLogoImage } = require('../controllers/uploadController');
const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
const prisma = require('../config/db');
@@ -17,11 +17,9 @@ router.post('/event-image', protect, supervisor, (req, res, next) => {
});
}, uploadEventImage);
// @route POST /api/uploads/logo
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
async function logoAccess(req, res, next) {
// Shared access rule for branding assets (logo, favicon): admin, OR allowed
// during first-time setup (no users yet).
async function brandingAssetAccess(req, res, next) {
try {
const count = await prisma.user.count();
if (count === 0) return next(); // first-time setup
@@ -31,11 +29,24 @@ async function logoAccess(req, res, next) {
}
}
router.post('/logo', logoAccess, (req, res, next) => {
// @route POST /api/uploads/logo
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
router.post('/logo', brandingAssetAccess, (req, res, next) => {
uploadLogo.single('image')(req, res, (err) => {
if (err) req.multerError = err;
next();
});
}, uploadLogoImage);
// @route POST /api/uploads/favicon
// @desc Upload site favicon — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
router.post('/favicon', brandingAssetAccess, (req, res, next) => {
uploadFavicon.single('image')(req, res, (err) => {
if (err) req.multerError = err;
next();
});
}, uploadFaviconImage);
module.exports = router;
+21 -8
View File
@@ -56,7 +56,7 @@ async function sendMail({ to, subject, html, text, attachments }) {
return;
}
const { transporter, cfg } = _getTransporter();
const from = cfg.from || 'no-reply@hope-events.local';
const from = cfg.from || 'no-reply@crosscode.local';
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) });
if (transporter.options && transporter.options.jsonTransport) {
@@ -74,12 +74,17 @@ async function sendMail({ to, subject, html, text, attachments }) {
function getOrg() {
const urlFallback = process.env.APP_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3001';
// headerColor prefers the new primary_color setting; falls back to the
// legacy accent_color value (which used to double as "the one brand color"
// before the 3-color Primary/Secondary/Accent system existed) so emails
// stay branded correctly until the admin re-saves the Branding tab.
const primary = getSettingSync('primary_color', '') || getSettingSync('accent_color', '');
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
tagline: getSettingSync('org_tagline', process.env.ORG_TAGLINE || 'Connecting community through events'),
email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
url: getSettingSync('app_base_url', urlFallback).replace(/\/$/, ''),
headerColor: getSettingSync('accent_color', process.env.EMAIL_HEADER_COLOR || '#1e3a5f'),
headerColor: primary || process.env.EMAIL_HEADER_COLOR || '#1e3a5f',
};
}
@@ -137,8 +142,9 @@ ${preheader ? `<div style="display:none;font-size:1px;line-height:1px;max-height
</html>`;
}
/** Renders a prominent CTA button. */
function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
/** Renders a prominent CTA button. Defaults to the org's brand color unless a semantic override (e.g. green for "activate", dark neutral for "log in") is passed explicitly. */
function ctaButton(label, url, { bg, fg = '#ffffff' } = {}) {
bg = bg || getOrg().headerColor;
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:28px auto 8px auto">
<tr><td align="center" style="border-radius:8px;background-color:${bg};mso-padding-alt:0px">
<a href="${url}" target="_blank"
@@ -150,8 +156,9 @@ function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
/** Renders a fallback link below a CTA button. */
function fallbackLink(url) {
const color = getOrg().headerColor;
return `<p style="text-align:center;margin:4px 0 0 0;font-size:12px;color:#94a3b8;word-break:break-all">
Or copy this link: <a href="${url}" style="color:#2563eb">${url}</a>
Or copy this link: <a href="${url}" style="color:${color}">${url}</a>
</p>`;
}
@@ -160,7 +167,12 @@ function divider() {
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
}
/** Coloured callout box. type: info | success | warning | danger | neutral */
/**
* Coloured callout box. type: info | success | warning | danger | neutral
* These 4 semantic colors are deliberately NOT brand-driven — a "this wasn't
* you" security warning must always read as urgent/red regardless of the
* org's brand color, so don't wire these to getOrg().headerColor.
*/
function callout(content, type = 'info') {
const map = {
info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' },
@@ -177,9 +189,10 @@ function callout(content, type = 'info') {
/** Numbered payment option row. */
function paymentOption(num, title, detail) {
const bg = getOrg().headerColor;
return `<tr>
<td style="padding:14px 16px 14px 0;vertical-align:top;width:28px">
<div style="width:26px;height:26px;border-radius:50%;background:#2563eb;color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
<div style="width:26px;height:26px;border-radius:50%;background:${bg};color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
</td>
<td style="padding:14px 0;border-bottom:1px solid #f1f5f9;vertical-align:top">
<p style="margin:0 0 4px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${title}</p>
+1 -1
View File
@@ -23,7 +23,7 @@ const { getSettingSync } = require('./settingsCache');
function getOrg() {
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
};
+1 -1
View File
@@ -23,7 +23,7 @@ const { getSettingSync } = require('./settingsCache');
function getOrg() {
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
};