Files
hope-events/backend/src/utils/whatsapp.js
T
joshuaandClaude Sonnet 5 0de3f4be7d Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page
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>
2026-08-04 14:52:54 +02:00

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,
};