Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.
Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
read-only "Report" tab that opens automatically once an event is closed.
Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
toggles), searchable/categorized report grid, and a popup viewer with
Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.
Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
disconnected session, instead of a bare axios status-code message.
Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
317 lines
11 KiB
JavaScript
317 lines
11 KiB
JavaScript
const axios = require('axios');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
|
|
const BASE = 'https://api.wawp.net/v2';
|
|
|
|
// ─── Config cache (DB-backed, env fallback) ───────────────────────────────────
|
|
|
|
let _configCache = null;
|
|
let _configCacheAt = 0;
|
|
const CONFIG_TTL_MS = 30_000; // 30 s
|
|
|
|
/**
|
|
* Load WAWP credentials from DB (AppSetting table), falling back to .env.
|
|
* Result is cached for 30 seconds so repeated calls don't hit the DB.
|
|
*/
|
|
async function getConfig() {
|
|
const now = Date.now();
|
|
if (_configCache && (now - _configCacheAt) < CONFIG_TTL_MS) return _configCache;
|
|
|
|
try {
|
|
const prisma = require('../config/db');
|
|
const rows = await prisma.appSetting.findMany({
|
|
where: { key: { in: ['WAWP_ACCESS_TOKEN', 'WAWP_INSTANCE_ID'] } },
|
|
});
|
|
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
|
|
_configCache = {
|
|
token: map.WAWP_ACCESS_TOKEN || process.env.WAWP_ACCESS_TOKEN || '',
|
|
instanceId: map.WAWP_INSTANCE_ID || process.env.WAWP_INSTANCE_ID || '',
|
|
};
|
|
} catch {
|
|
// DB unavailable — fall back to env vars
|
|
_configCache = {
|
|
token: process.env.WAWP_ACCESS_TOKEN || '',
|
|
instanceId: process.env.WAWP_INSTANCE_ID || '',
|
|
};
|
|
}
|
|
_configCacheAt = now;
|
|
return _configCache;
|
|
}
|
|
|
|
/** Save WAWP credentials to DB and invalidate the cache. */
|
|
async function setConfig(token, instanceId) {
|
|
const prisma = require('../config/db');
|
|
await prisma.$transaction([
|
|
prisma.appSetting.upsert({
|
|
where: { key: 'WAWP_ACCESS_TOKEN' },
|
|
update: { value: token },
|
|
create: { key: 'WAWP_ACCESS_TOKEN', value: token },
|
|
}),
|
|
prisma.appSetting.upsert({
|
|
where: { key: 'WAWP_INSTANCE_ID' },
|
|
update: { value: instanceId },
|
|
create: { key: 'WAWP_INSTANCE_ID', value: instanceId },
|
|
}),
|
|
]);
|
|
_configCache = null; // force reload on next getConfig()
|
|
}
|
|
|
|
async function isConfigured() {
|
|
const { token, instanceId } = await getConfig();
|
|
return !!(token && instanceId);
|
|
}
|
|
|
|
// ─── SA phone normalisation ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Normalises any common South African phone format to 27xxxxxxxxx (11 digits).
|
|
* Handles: 0821234567 / 082 123 4567 / +27821234567 / +27 82 123 4567
|
|
* Returns null when the number cannot be resolved to a valid SA mobile.
|
|
*/
|
|
function normalizeZAPhone(raw) {
|
|
if (!raw) return null;
|
|
let digits = String(raw).replace(/\D/g, '');
|
|
|
|
// Local format: leading 0 + 9 digits (total 10)
|
|
if (digits.startsWith('0') && digits.length === 10) {
|
|
digits = '27' + digits.slice(1);
|
|
}
|
|
|
|
// Must now be exactly 11 digits starting with 27
|
|
if (/^27\d{9}$/.test(digits)) return digits;
|
|
return null;
|
|
}
|
|
|
|
/** Returns true when raw is a valid SA mobile number. */
|
|
function isValidZAPhone(raw) {
|
|
return normalizeZAPhone(raw) !== null;
|
|
}
|
|
|
|
/** Converts a phone number to the WAWP chatId format (e.g. 27821234567@c.us). */
|
|
function toChatId(raw) {
|
|
const n = normalizeZAPhone(raw);
|
|
return n ? `${n}@c.us` : null;
|
|
}
|
|
|
|
// ─── "Session not found" auto-recovery ───────────────────────────────────────
|
|
|
|
/**
|
|
* Returns true when the WAWP error message indicates the session doesn't exist.
|
|
*/
|
|
function isSessionNotFound(e) {
|
|
const msg = (e?.response?.data?.message || e?.message || '').toLowerCase();
|
|
return msg.includes('session not found') || msg.includes('instance not found');
|
|
}
|
|
|
|
/** Extracts the WAWP API's own error reason when present, instead of axios's generic
|
|
* "Request failed with status code NNN" (which carries no information about what went wrong). */
|
|
function wawpErrorMessage(e) {
|
|
return e?.response?.data?.message || e?.message || 'Unknown WhatsApp API error';
|
|
}
|
|
|
|
/**
|
|
* If the WAWP API reports "Session not found", clear the stale instance ID
|
|
* from the DB so the admin UI drops back to the Session Instance setup step.
|
|
*
|
|
* @param {Error} e - The error thrown by a WAWP API call
|
|
*/
|
|
async function handleSessionNotFound(e) {
|
|
if (!isSessionNotFound(e)) throw e; // not our problem — re-throw
|
|
|
|
console.warn('[whatsapp] Session not found — clearing instance ID from DB...');
|
|
const { token, instanceId } = await getConfig();
|
|
|
|
// Try to delete the stale session on WAWP (may fail — that's okay)
|
|
try {
|
|
await axios.post(`${BASE}/session/delete`, { access_token: token, instance_id: instanceId });
|
|
} catch (delErr) {
|
|
console.warn('[whatsapp] Delete stale session failed (ignored):', delErr?.response?.data?.message || delErr.message);
|
|
}
|
|
|
|
// Clear the instance ID from DB so the frontend goes back to Step 2
|
|
await setConfig(token, '');
|
|
console.info('[whatsapp] Instance ID cleared — admin must set up a new session instance.');
|
|
|
|
throw new Error('SESSION_NOT_FOUND');
|
|
}
|
|
|
|
// ─── Session management ───────────────────────────────────────────────────────
|
|
|
|
async function getStatus() {
|
|
const { token, instanceId } = await getConfig();
|
|
try {
|
|
const res = await axios.post(`${BASE}/session/info`, { access_token: token, instance_id: instanceId });
|
|
return res.data;
|
|
} catch (e) {
|
|
await handleSessionNotFound(e);
|
|
}
|
|
}
|
|
|
|
async function startSession() {
|
|
const { token, instanceId } = await getConfig();
|
|
try {
|
|
const res = await axios.post(`${BASE}/session/start`, { access_token: token, instance_id: instanceId });
|
|
return res.data;
|
|
} catch (e) {
|
|
await handleSessionNotFound(e);
|
|
}
|
|
}
|
|
|
|
async function restartSession() {
|
|
const { token, instanceId } = await getConfig();
|
|
try {
|
|
const res = await axios.post(`${BASE}/session/restart`, { access_token: token, instance_id: instanceId });
|
|
return res.data;
|
|
} catch (e) {
|
|
await handleSessionNotFound(e);
|
|
}
|
|
}
|
|
|
|
async function logoutSession() {
|
|
const { token, instanceId } = await getConfig();
|
|
const res = await axios.post(`${BASE}/session/logout`, { access_token: token, instance_id: instanceId });
|
|
return res.data;
|
|
}
|
|
|
|
async function createInstance(name) {
|
|
const { token } = await getConfig();
|
|
const label = name || `hope-events-${Date.now()}`;
|
|
const res = await axios.post(`${BASE}/session/create`, { access_token: token, name: label });
|
|
const newInstanceId = res.data?.instance_id || res.data?.id;
|
|
if (!newInstanceId) throw new Error('Create instance returned no instance_id');
|
|
await setConfig(token, newInstanceId);
|
|
return { ...res.data, instance_id: newInstanceId };
|
|
}
|
|
|
|
async function deleteInstance() {
|
|
const { token, instanceId } = await getConfig();
|
|
if (!instanceId) throw new Error('No instance configured');
|
|
const res = await axios.post(`${BASE}/session/delete`, { access_token: token, instance_id: instanceId });
|
|
// Clear instance_id from DB after deletion
|
|
await setConfig(token, '');
|
|
return res.data;
|
|
}
|
|
|
|
async function getQr() {
|
|
const { token, instanceId } = await getConfig();
|
|
try {
|
|
const res = await axios.post(`${BASE}/auth/qr-image`, { access_token: token, instance_id: instanceId });
|
|
return res.data; // { qr: 'data:image/png;base64,...' }
|
|
} catch (e) {
|
|
await handleSessionNotFound(e);
|
|
}
|
|
}
|
|
|
|
async function requestPairingCode(phoneNumber) {
|
|
const { token, instanceId } = await getConfig();
|
|
const normalized = normalizeZAPhone(phoneNumber);
|
|
if (!normalized) throw new Error('Invalid South African phone number');
|
|
try {
|
|
const res = await axios.post(`${BASE}/auth/request-code`, {
|
|
access_token: token,
|
|
instance_id: instanceId,
|
|
phone_number: normalized,
|
|
});
|
|
return res.data; // { code: 'ABCD-1234' }
|
|
} catch (e) {
|
|
await handleSessionNotFound(e);
|
|
}
|
|
}
|
|
|
|
// ─── Messaging ────────────────────────────────────────────────────────────────
|
|
|
|
async function sendText(toPhone, message) {
|
|
if (!(await isConfigured())) return;
|
|
const chatId = toChatId(toPhone);
|
|
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; }
|
|
const { token, instanceId } = await getConfig();
|
|
try {
|
|
await axios.post(`${BASE}/send/text`, {
|
|
access_token: token,
|
|
instance_id: instanceId,
|
|
chatId,
|
|
message,
|
|
});
|
|
} catch (e) {
|
|
if (isSessionNotFound(e)) {
|
|
try { await handleSessionNotFound(e); } catch {}
|
|
throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.');
|
|
}
|
|
throw new Error(`WhatsApp text send failed: ${wawpErrorMessage(e)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copies a local PDF to a temporary public URL, sends it via WAWP,
|
|
* then schedules the temp file for deletion after 5 minutes.
|
|
*/
|
|
async function sendPdf(toPhone, localPdfPath, filename, caption) {
|
|
if (!(await isConfigured())) return;
|
|
const chatId = toChatId(toPhone);
|
|
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping PDF:', toPhone); return; }
|
|
|
|
const tempDir = path.join(__dirname, '../../public/uploads/tickets-temp');
|
|
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
|
|
|
|
const tempName = `${uuidv4()}.pdf`;
|
|
const tempPath = path.join(tempDir, tempName);
|
|
fs.copyFileSync(localPdfPath, tempPath);
|
|
|
|
const backendUrl = (process.env.BACKEND_URL || '').replace(/\/$/, '');
|
|
const isLocalhost = !backendUrl || /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(backendUrl);
|
|
if (isLocalhost) {
|
|
try { fs.unlinkSync(tempPath); } catch {}
|
|
throw new Error(
|
|
`WhatsApp PDF delivery requires a publicly accessible backend URL. ` +
|
|
`BACKEND_URL is currently "${backendUrl || '(not set)'}". ` +
|
|
`Set BACKEND_URL to your public backend URL (e.g. https://api.yourdomain.com) in your .env file.`
|
|
);
|
|
}
|
|
const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`;
|
|
|
|
const { token, instanceId } = await getConfig();
|
|
try {
|
|
await axios.post(`${BASE}/send/pdf`, {
|
|
access_token: token,
|
|
instance_id: instanceId,
|
|
chatId,
|
|
file: {
|
|
url: pdfUrl,
|
|
filename: filename || 'tickets.pdf',
|
|
mimetype: 'application/pdf',
|
|
},
|
|
caption: caption || '',
|
|
});
|
|
} catch (e) {
|
|
try { fs.unlinkSync(tempPath); } catch {}
|
|
if (isSessionNotFound(e)) {
|
|
try { await handleSessionNotFound(e); } catch {}
|
|
throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.');
|
|
}
|
|
throw new Error(`WhatsApp PDF send failed: ${wawpErrorMessage(e)}`);
|
|
}
|
|
|
|
// Clean up after 5 minutes — WAWP will have fetched the file by then
|
|
setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000);
|
|
}
|
|
|
|
module.exports = {
|
|
getConfig,
|
|
setConfig,
|
|
normalizeZAPhone,
|
|
isValidZAPhone,
|
|
toChatId,
|
|
isConfigured,
|
|
getStatus,
|
|
startSession,
|
|
restartSession,
|
|
logoutSession,
|
|
createInstance,
|
|
deleteInstance,
|
|
getQr,
|
|
requestPairingCode,
|
|
sendText,
|
|
sendPdf,
|
|
}; |