Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar shell, per-page help guides, and a layout/content pass across every remaining page) plus backend fixes to the dashboard KPI stats: - Admin/Supervisor dashboard KPIs (revenue, donations, registrations, tickets sold) now use a rolling trailing-month window (today back one calendar month, e.g. 9 May - 8 June if today is 8 June) instead of calendar month-to-date, which under-counted for most of the month. The comparison window shifts the same way, so like is still compared with like. - Reports deep-links from those stat tiles now match the same window (range=trailing_month, replacing range=this_month). - Design tokens (brand-* Tailwind scale + shadcn CSS variables), a site-wide contextual help button, fixed dashboard sidebar/navbar, Admin/Supervisor/Staff/User dashboard rebuilds backed by a new GET /api/stats/overview endpoint, a dedicated Contact page, Site Settings restyle with WhatsApp config folded in, and an Account activity feed backed by a new SecurityEvent model. - Every remaining page (home, events, registration flow, auth, legal, payment results, and every Admin/Supervisor/Staff/User tool page) restyled onto the same design tokens, several with real layout upgrades (home hero, events list/detail, donate page, auth pages). - 20+ new dedicated help guides so the whole site has page-specific help content instead of falling back to a generic guide. - Assorted fixes surfaced along the way: donation-leg double-counting in payment stats, donations not counting toward revenue, refund netting in per-method report breakdowns, and donation over-allocation after a refund. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
224 lines
7.9 KiB
JavaScript
224 lines
7.9 KiB
JavaScript
const {
|
|
getConfig, setConfig,
|
|
getStatus, startSession, restartSession, logoutSession,
|
|
getQr, requestPairingCode,
|
|
createInstance, deleteInstance,
|
|
} = require('../utils/whatsapp');
|
|
const { sendMail } = require('../utils/email');
|
|
|
|
// ─── Config management ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/whatsapp/config
|
|
* Returns the current WAWP credentials (token is masked).
|
|
*/
|
|
const getConfigHandler = async (req, res) => {
|
|
try {
|
|
const { token, instanceId } = await getConfig();
|
|
// Mask the token for display — show first 4 chars + asterisks
|
|
const maskedToken = token
|
|
? token.slice(0, 4) + '*'.repeat(Math.max(0, token.length - 4))
|
|
: '';
|
|
res.json({
|
|
tokenMasked: maskedToken,
|
|
instanceId: instanceId || '',
|
|
hasToken: !!token,
|
|
hasInstance: !!instanceId,
|
|
configured: !!(token && instanceId),
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ message: e?.message || 'Failed to load config' });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* POST /api/whatsapp/config
|
|
* Body: { token, instanceId }
|
|
* Saves credentials to DB; instanceId is optional (keep existing if omitted).
|
|
*/
|
|
const saveConfigHandler = async (req, res) => {
|
|
try {
|
|
const { token, instanceId } = req.body || {};
|
|
|
|
const current = await getConfig();
|
|
|
|
// Resolve token: '_clear_' resets it; blank/absent keeps existing
|
|
let resolvedToken = current.token;
|
|
if (token === '_clear_') {
|
|
resolvedToken = '';
|
|
} else if (token && token.trim()) {
|
|
resolvedToken = token.trim();
|
|
}
|
|
|
|
// Resolve instanceId: blank/absent keeps existing
|
|
const resolvedInstanceId = (instanceId && instanceId.trim())
|
|
? instanceId.trim()
|
|
: current.instanceId || '';
|
|
|
|
await setConfig(resolvedToken, resolvedInstanceId);
|
|
res.json({ message: 'WAWP configuration saved successfully.' });
|
|
} catch (e) {
|
|
res.status(500).json({ message: e?.message || 'Failed to save config' });
|
|
}
|
|
};
|
|
|
|
// ─── Instance lifecycle ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* POST /api/whatsapp/create-instance
|
|
* Body: { name? }
|
|
* Creates a new WAWP session instance and saves its id to the DB.
|
|
*/
|
|
const createInstanceHandler = async (req, res) => {
|
|
try {
|
|
const { name } = req.body || {};
|
|
const data = await createInstance(name);
|
|
res.json({ message: 'Instance created and saved.', ...data });
|
|
} catch (e) {
|
|
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* POST /api/whatsapp/delete-instance
|
|
* Deletes the current WAWP instance and clears it from the DB.
|
|
*/
|
|
const deleteInstanceHandler = async (req, res) => {
|
|
try {
|
|
const data = await deleteInstance();
|
|
res.json({ message: 'Instance deleted.', ...data });
|
|
} catch (e) {
|
|
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
|
}
|
|
};
|
|
|
|
// ─── Admin session endpoints ──────────────────────────────────────────────────
|
|
|
|
const getStatusHandler = async (req, res) => {
|
|
try {
|
|
res.json(await getStatus());
|
|
} catch (e) {
|
|
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
|
}
|
|
};
|
|
|
|
const getQrHandler = async (req, res) => {
|
|
try {
|
|
const data = await getQr();
|
|
// Normalise: strip leading "data:image/png;base64," if WAWP already includes it,
|
|
// so the client always receives a clean base64 string it can prefix itself.
|
|
if (data?.qr) {
|
|
data.qr = data.qr.replace(/^data:image\/png;base64,/, '');
|
|
}
|
|
res.json(data);
|
|
} catch (e) {
|
|
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
|
}
|
|
};
|
|
|
|
const requestCodeHandler = async (req, res) => {
|
|
try {
|
|
const { phoneNumber } = req.body || {};
|
|
if (!phoneNumber) return res.status(400).json({ message: 'phoneNumber is required' });
|
|
res.json(await requestPairingCode(phoneNumber));
|
|
} catch (e) {
|
|
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
|
}
|
|
};
|
|
|
|
const logoutHandler = async (req, res) => {
|
|
try {
|
|
res.json(await logoutSession());
|
|
} catch (e) {
|
|
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
|
}
|
|
};
|
|
|
|
const startHandler = async (req, res) => {
|
|
try {
|
|
res.json(await startSession());
|
|
} catch (e) {
|
|
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
|
}
|
|
};
|
|
|
|
const restartHandler = async (req, res) => {
|
|
try {
|
|
res.json(await restartSession());
|
|
} catch (e) {
|
|
res.status(500).json({ message: e?.response?.data?.message || e.message });
|
|
}
|
|
};
|
|
|
|
// ─── Webhook — auto-recovery ──────────────────────────────────────────────────
|
|
|
|
const MAX_ATTEMPTS = 3;
|
|
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // ms between each attempt
|
|
const STATUS_WAIT_MS = 10_000; // wait after restart before checking
|
|
|
|
const handleWebhook = async (req, res) => {
|
|
// Acknowledge immediately so WAWP doesn't time out
|
|
res.status(200).json({ ok: true });
|
|
|
|
try {
|
|
const { event, session } = req.body || {};
|
|
if (event !== 'session.status') return;
|
|
|
|
const status = session?.status;
|
|
// Only auto-recover on FAILED — STOPPED may be intentional
|
|
if (status !== 'FAILED') return;
|
|
|
|
console.warn('[whatsapp webhook] Session FAILED — starting recovery...');
|
|
|
|
let recovered = false;
|
|
|
|
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
await new Promise(r => setTimeout(r, RETRY_DELAYS[i]));
|
|
try {
|
|
await restartSession();
|
|
await new Promise(r => setTimeout(r, STATUS_WAIT_MS));
|
|
const info = await getStatus();
|
|
const s = info?.status;
|
|
if (s === 'WORKING' || s === 'SCAN_QR_CODE' || s === 'STARTING') {
|
|
recovered = true;
|
|
console.info(`[whatsapp webhook] Session recovered on attempt ${i + 1} (status: ${s})`);
|
|
break;
|
|
}
|
|
console.warn(`[whatsapp webhook] Attempt ${i + 1}: status still ${s}`);
|
|
} catch (e) {
|
|
console.warn(`[whatsapp webhook] Attempt ${i + 1} error:`, e.message);
|
|
}
|
|
}
|
|
|
|
if (!recovered) {
|
|
console.error(`[whatsapp webhook] Could not recover after ${MAX_ATTEMPTS} attempts — sending admin alert`);
|
|
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 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>`,
|
|
}).catch(() => {});
|
|
}
|
|
} catch (e) {
|
|
console.error('[whatsapp webhook] Unhandled error in recovery handler:', e.message);
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
getConfigHandler,
|
|
saveConfigHandler,
|
|
createInstanceHandler,
|
|
deleteInstanceHandler,
|
|
getStatusHandler,
|
|
getQrHandler,
|
|
requestCodeHandler,
|
|
logoutHandler,
|
|
startHandler,
|
|
restartHandler,
|
|
handleWebhook,
|
|
}; |