Files
hope-events/backend/src/controllers/whatsappController.js
T
joshuaandClaude Opus 5.5 a1ecbb9617 POPIA toolkit part 3: retention, breach register, readiness
- Retention policies (Admin -> Privacy -> Retention) applied by a nightly
  `retention` job: inactive accounts anonymised after 5 years with a
  30-day warning (on by default; never staff roles or people with an
  upcoming registration), form answers, login history, payer details in
  payment notifications, audit/access logs
- User.lastLoginAt (set on login, backfilled) and inactivityWarnedAt
- Breach register (SecurityBreach) with drafts for the people affected
  and the Information Regulator
- Readiness checklist with a score
- Children's/medical flag in the access log; {retention} placeholder in
  the privacy notice
- Unit tests (retention.test.js), e2e (privacy-retention.spec.ts); docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 20:25:58 +02:00

263 lines
9.8 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),
webhookUrl: await require('../utils/whatsapp').webhookUrl(),
});
} 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 === 'message') return await handleIncomingMessage(req);
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 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 ${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: `<p>The ${orgName} 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);
}
};
/**
* An incoming WhatsApp message (WAWP `message` event). The only thing we act on is an
* opt-out reply — "STOP" — which stops News & announcements on WhatsApp for that number
* (event updates still go out). Only trusted when the URL carries our webhook secret,
* since WAWP doesn't sign webhooks.
*/
async function handleIncomingMessage(req) {
const crypto = require('crypto');
const { getWebhookSecret, phoneFromChatId, sendText } = require('../utils/whatsapp');
const { isStopKeyword, whatsAppStop } = require('../utils/privacy');
const given = String(req.query?.secret || '');
const secret = await getWebhookSecret();
if (given.length !== secret.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(secret))) {
console.warn('[whatsapp webhook] Ignored a message event without a valid secret');
return;
}
// Proof for the POPIA readiness page that WAWP's webhook (and so STOP) is set up.
const prisma = require('../config/db');
const now = new Date().toISOString();
prisma.appSetting.upsert({ where: { key: 'wawp_webhook_last_seen' }, update: { value: now }, create: { key: 'wawp_webhook_last_seen', value: now } }).catch(() => {});
const payload = req.body?.payload || {};
if (payload.fromMe || !isStopKeyword(payload.body)) return;
const phone = phoneFromChatId(payload.from);
if (!phone) {
// Groups, channels, and senders WhatsApp only identifies by a "LID" (no phone number).
console.warn('[whatsapp webhook] STOP from a sender without a phone number:', payload.from);
return;
}
const result = await whatsAppStop(phone);
if (!result) return;
await sendText(phone, "You won't get news and announcements from us on WhatsApp any more. You'll still get messages about events you've registered for.").catch(() => {});
}
module.exports = {
getConfigHandler,
saveConfigHandler,
createInstanceHandler,
deleteInstanceHandler,
getStatusHandler,
getQrHandler,
requestCodeHandler,
logoutHandler,
startHandler,
restartHandler,
handleWebhook,
};