diff --git a/CHANGELOG.md b/CHANGELOG.md
index 673bc5c..b410aad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [1.7.0] - 2026-08-20
+
+### Added
+
+- Site Settings → Branding now supports a full Primary/Secondary/Accent brand color system (previously a single accent color that only recolored the org name in the navbar) applied site-wide — buttons, nav highlights, hover states, and links — plus a favicon upload alongside the existing logo upload, and a live preview panel (with website/email and desktop/mobile views) that updates as colors are picked, before saving. Selecting a logo now also suggests brand colors extracted from it. Outgoing emails' header and call-to-action buttons now pick up the Primary color too, while semantic alert colors (success/warning/danger) stay fixed. The setup wizard's Branding step got the same preview and color-suggestion treatment.
+
+### Fixed
+
+- The setup wizard's logo/favicon upload was silently failing — by the time it ran, the admin account already existed and the upload endpoint required admin auth, but the request never attached the token. It now does, and a failed upload surfaces as a visible error instead of quietly leaving branding unset.
+- A custom favicon saved via Site Settings never actually showed in the browser tab: the site had a static `favicon.ico` in the special Next.js `app/` convention path, which generates its own `` tag that always took priority over the admin's saved one. Moved the static file to `public/` so it's just the default fallback, not a competing tag.
+- Every unconfigured-organisation default across the app (org name, email subjects, WhatsApp messages, report file metadata, backend status/API-docs pages, `From:` fallback address) said "Hope Events"/"Hope Family Church" instead of a neutral placeholder — now defaults to "Cross Code" (the platform vendor) consistently. Several email subject lines (login/welcome/activation/password-changed/account-closed) were also fully hardcoded regardless of the configured org name; they now interpolate it dynamically like the email body already did.
+- The optional legal settings (operator name, Information Officer name/email, website URL, effective date, and the Legal pages' own email fallback) defaulted to this project's real founder's name/email/church details — actively misleading if ever shown on another organisation's deployed site before they're configured. These now default to obviously-generic placeholders (e.g. `example.com`, `[not yet configured]`) instead.
+- Added `SETTINGS.md`, a repo-root reference documenting every setting's default behaviour when left unconfigured.
+
## [1.6.0] - 2026-08-08
### Added
diff --git a/README.md b/README.md
index 94d9844..bf85b52 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,7 @@ Documentation: see Frontend README for environment and scripts.
- Backend API Documentation: ./backend/API_DOCUMENTATION.md
- Backend README: ./backend/README.md
- Frontend README: ./frontend/README.md
+- Site Settings — defaults reference: ./SETTINGS.md
## Recent Changes
diff --git a/SETTINGS.md b/SETTINGS.md
new file mode 100644
index 0000000..f26cd2a
--- /dev/null
+++ b/SETTINGS.md
@@ -0,0 +1,81 @@
+# Site Settings — Defaults Reference
+
+Every setting below is stored as a row in the `AppSetting` key-value table and
+is editable at runtime via **Admin → Site Settings** (or, for the first few,
+the `/setup` wizard on a fresh install). None of them are required — this
+page documents exactly what happens when a given key has never been saved.
+
+Settings changes take effect within 60 seconds everywhere (the backend's
+in-process settings cache TTL) without a server restart.
+
+## Organisation
+
+| Setting | Default when unset | Notes |
+|---|---|---|
+| `org_name` | `"Cross Code"` | Falls back to `ORG_NAME` env var first, then this hardcoded default (the platform vendor's name, used as a neutral placeholder — `org_name` is a required field in the setup wizard, so this fallback is practically unreachable once a site has been set up). Shown in the navbar, email headers/subjects, and WhatsApp messages. |
+| `org_tagline` | `"Connecting community through events"` | Falls back to `ORG_TAGLINE` env var first. Shown under the org name in the email header. |
+| `org_email` | *(blank)* | No fallback in most places. **Exception:** the Terms of Use / Privacy Policy pages default to `privacy@example.com` (an obvious placeholder) if unset — see the Legal section below. |
+| `org_phone` / `org_address` | *(blank)* | No fallback — simply omitted wherever shown if unset. |
+| `app_base_url` | `APP_BASE_URL` or `FRONTEND_URL` env var, else `http://localhost:3001` | Used to build links in emails (password reset, ticket delivery, etc.). Should always be set in production. |
+
+## Branding
+
+| Setting | Default when unset | Notes |
+|---|---|---|
+| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
+| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
+| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
+| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
+| `favicon_url` | `frontend/public/favicon.ico` (the site's original generic favicon) | Shown in the browser tab. |
+
+### Two different fallbacks for `primary_color`
+
+If `primary_color` has *never* been saved (no admin has ever used the new
+3-color Branding tab) **and** there's no legacy `accent_color` value either,
+the website and outgoing emails currently disagree on what "no color" means:
+
+- **Website** (`globals.css` / `tailwind.config.js` static defaults): `#4F46E5`, a bright indigo.
+- **Emails** (`backend/src/utils/email.js` `getOrg()`): `#1e3a5f`, a dark navy — unless overridden by the `EMAIL_HEADER_COLOR` env var.
+
+In practice this only matters on a brand-new install before the setup wizard
+or Branding tab has been saved even once — the wizard always writes
+`primary_color`, so this gap closes itself the moment setup completes.
+
+## Notifications
+
+| Setting | Default when unset | Notes |
+|---|---|---|
+| `reg_notification_emails` | `REGISTRATIONS_EMAIL` env var, else *(blank)* | If truly blank, no internal notification email is sent for new registrations/payments/donations — it's silently skipped, not an error. |
+
+## Email delivery (SMTP)
+
+| Setting | Default when unset | Notes |
+|---|---|---|
+| `smtp_host` | `SMTP_HOST`/`EMAIL_HOST` env var, else *(blank)* | If blank, the app **does not send real email** — it uses nodemailer's `jsonTransport`, which just logs the would-be email to the console. This is the single biggest "nothing is configured" gotcha — ticket/payment/password-reset emails silently don't go anywhere until SMTP is set up. |
+| `smtp_port` | `587` | |
+| `smtp_secure` | `false` | |
+| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
+| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
+
+## Legal (Terms of Use / Privacy Policy)
+
+| Setting | Default when unset | Notes |
+|---|---|---|
+| `legal_operator_name` | `"[operator details not yet configured]"` | Deliberately a generic, obviously-incomplete placeholder rather than any real person's or organisation's name. Unlike `org_name`, these legal fields are optional and can realistically stay unset on a live site — defaulting them to Cross Code's (or anyone else's) real identity would misrepresent who's legally responsible for that deployment. |
+| `legal_io_name` | `"[not yet configured]"` | Same reasoning as `legal_operator_name`. |
+| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
+| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
+| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
+| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
+
+## WhatsApp
+
+| Setting | Default when unset | Notes |
+|---|---|---|
+| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
+
+## Setup state
+
+| Setting | Default when unset | Notes |
+|---|---|---|
+| `setup_complete` | `false` (treated as `"not true"`) | Controls whether `/setup` redirects to `/login` or runs the first-time wizard. Set to `"true"` automatically when the wizard finishes. |
diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md
index 89f23af..8233b49 100644
--- a/backend/API_DOCUMENTATION.md
+++ b/backend/API_DOCUMENTATION.md
@@ -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) |
diff --git a/backend/package.json b/backend/package.json
index eea4792..1042e7b 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
- "version": "1.6.0",
+ "version": "1.7.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
diff --git a/backend/scripts/migrate-env-to-db.js b/backend/scripts/migrate-env-to-db.js
index bb6d450..3af2623 100644
--- a/backend/scripts/migrate-env-to-db.js
+++ b/backend/scripts/migrate-env-to-db.js
@@ -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`);
diff --git a/backend/src/controllers/reportController.js b/backend/src/controllers/reportController.js
index d796b8c..2139013 100644
--- a/backend/src/controllers/reportController.js
+++ b/backend/src/controllers/reportController.js
@@ -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);
diff --git a/backend/src/controllers/settingsController.js b/backend/src/controllers/settingsController.js
index ede9ff5..fa23c77 100644
--- a/backend/src/controllers/settingsController.js
+++ b/backend/src/controllers/settingsController.js
@@ -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: `
This is a test email sent from the Hope Events admin panel to confirm that your SMTP settings are working correctly.
Host: ${host}:${port} From: ${from || user}
`,
+ 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: `
This is a test email sent from the ${orgName} admin panel to confirm that your SMTP settings are working correctly.
Host: ${host}:${port} From: ${from || user}
`,
});
}
diff --git a/backend/src/controllers/uploadController.js b/backend/src/controllers/uploadController.js
index 31f6f7c..6053ac9 100644
--- a/backend/src/controllers/uploadController.js
+++ b/backend/src/controllers/uploadController.js
@@ -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,
};
diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js
index 18a0e6c..0dd4b17 100644
--- a/backend/src/controllers/userController.js
+++ b/backend/src/controllers/userController.js
@@ -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.' });
diff --git a/backend/src/controllers/whatsappController.js b/backend/src/controllers/whatsappController.js
index ba70f0d..6c06113 100644
--- a/backend/src/controllers/whatsappController.js
+++ b/backend/src/controllers/whatsappController.js
@@ -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: `
The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.
`,
+ 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: `
The ${orgName} WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.
+ const html = pageShell('Cross Code Events API — Status', '#2563eb', `
+
Cross Code Events API
v${API_VERSION} — ${now}
@@ -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) => {
- Hope Events — API Docs
+ Cross Code Events — API Docs