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.

Time: ${when}

Please visit the admin dashboard to re-scan the QR code and reconnect.

`, + 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.

Time: ${when}

Please visit the admin dashboard to re-scan the QR code and reconnect.

`, }).catch(() => {}); } } catch (e) { diff --git a/backend/src/index.js b/backend/src/index.js index 834e6c0..6b6cc36 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -244,8 +244,8 @@ app.get('/', async (req, res) => { ? `testing` : `development`; - const html = pageShell('Hope Events API — Status', '#2563eb', ` -

Hope Events API

+ 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