Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
await axios.post(`${BASE}/send/text`, {
|
||||
access_token: token,
|
||||
instance_id: instanceId,
|
||||
chatId,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
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 || '',
|
||||
});
|
||||
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user