diff --git a/backend/migrations/core/135_add_category_allow_downloads.js b/backend/migrations/core/135_add_category_allow_downloads.js
new file mode 100644
index 00000000..b7f9aaff
--- /dev/null
+++ b/backend/migrations/core/135_add_category_allow_downloads.js
@@ -0,0 +1,33 @@
+/**
+ * Migration 135: per-category download permissions (#640).
+ *
+ * Adds an `allow_downloads` boolean to `photo_categories` so admins can have
+ * different download policies per category (e.g. preview categories public,
+ * originals client-only). The flag is an AND with the event-level
+ * `allow_downloads`: a category download is allowed only when BOTH the
+ * event AND the category say yes. Defaults to true so existing categories
+ * keep working without admin intervention.
+ *
+ * Additive + hasColumn-guarded.
+ */
+async function addColumn(knex, table, column, builder) {
+ if (!(await knex.schema.hasColumn(table, column))) {
+ await knex.schema.alterTable(table, builder);
+ }
+}
+
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('photo_categories'))) return;
+ await addColumn(knex, 'photo_categories', 'allow_downloads', (t) =>
+ t.boolean('allow_downloads').notNullable().defaultTo(true)
+ );
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('photo_categories'))) return;
+ if (await knex.schema.hasColumn('photo_categories', 'allow_downloads')) {
+ await knex.schema.alterTable('photo_categories', (t) =>
+ t.dropColumn('allow_downloads')
+ );
+ }
+};
diff --git a/backend/migrations/core/136_create_whatsapp_tables.js b/backend/migrations/core/136_create_whatsapp_tables.js
new file mode 100644
index 00000000..676f4318
--- /dev/null
+++ b/backend/migrations/core/136_create_whatsapp_tables.js
@@ -0,0 +1,67 @@
+/**
+ * Migration 136: WhatsApp Business API notification channel (#640 part D).
+ *
+ * Adds an alternative to the email channel for the gallery-created
+ * notification — useful in markets where customers expect WhatsApp by default
+ * (DACH photographers report this frequently). Strictly opt-in via the
+ * `whatsapp` feature flag; defaults OFF on every install.
+ *
+ * Two tables:
+ * - whatsapp_configs : single-row config (Meta phone_number_id, waba_id,
+ * access_token, template_name). Token is admin-only,
+ * masked on GET, never returned in plaintext outside
+ * the route layer.
+ * - whatsapp_queue : per-message queue mirroring email_queue's shape —
+ * recipient, message_type, message_data JSON, retry
+ * count, error_message. Polled by the WhatsApp queue
+ * processor every 30s.
+ *
+ * Loose-FK on event_id by design — matches `inbound_documents.event_id` and
+ * `expenses.event_id` and avoids the RESTRICT-on-delete problem (deleting an
+ * event shouldn't fail because a stale queue row references it).
+ *
+ * Ported from filpgame's #1 with adjustments: loose-FK, renumbered to next
+ * free migration slot, schema otherwise compatible.
+ */
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('whatsapp_configs'))) {
+ await knex.schema.createTable('whatsapp_configs', (table) => {
+ table.increments('id').primary();
+ table.string('phone_number_id', 255).notNullable().defaultTo('');
+ table.string('waba_id', 255).notNullable().defaultTo('');
+ // Meta access tokens are long-lived JWT-style strings; 1000 chars
+ // covers system-user tokens with comfortable headroom.
+ table.string('access_token', 1000).notNullable().defaultTo('');
+ table.string('template_name', 255).notNullable().defaultTo('gallery_ready');
+ table.boolean('enabled').notNullable().defaultTo(false);
+ table.timestamp('updated_at').defaultTo(knex.fn.now());
+ });
+ }
+
+ if (!(await knex.schema.hasTable('whatsapp_queue'))) {
+ await knex.schema.createTable('whatsapp_queue', (table) => {
+ table.increments('id').primary();
+ // Loose-FK: event_id references events.id but no FK constraint, so an
+ // event delete doesn't RESTRICT against stale queue rows.
+ table.integer('event_id').unsigned();
+ table.string('recipient_phone', 50).notNullable();
+ table.string('message_type', 50).notNullable();
+ table.json('message_data');
+ table.string('status', 20).notNullable().defaultTo('pending');
+ table.integer('retry_count').notNullable().defaultTo(0);
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+ table.timestamp('scheduled_at').defaultTo(knex.fn.now());
+ table.timestamp('sent_at');
+ table.text('error_message');
+ // Index the poll path: pending + retry_count < threshold, ordered by
+ // created_at. Single composite index covers all three.
+ table.index(['status', 'retry_count', 'created_at'], 'whatsapp_queue_poll_index');
+ table.index(['event_id']);
+ });
+ }
+};
+
+exports.down = async function (knex) {
+ await knex.schema.dropTableIfExists('whatsapp_queue');
+ await knex.schema.dropTableIfExists('whatsapp_configs');
+};
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 04c4e230..ab876ed3 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,17 +1,16 @@
{
"name": "picpeak-backend",
- "version": "3.60.6-beta.0",
+ "version": "3.65.1-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
- "version": "3.60.6-beta.0",
+ "version": "3.65.1-beta.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
- "adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "1.15.2",
"bcrypt": "6.0.0",
@@ -39,6 +38,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
+ "node-stream-zip": "^1.15.0",
"nodemailer": "^8.0.5",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
@@ -314,6 +314,7 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz",
"integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
@@ -1042,6 +1043,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -3804,6 +3806,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3821,15 +3824,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
- "node_modules/adm-zip": {
- "version": "0.5.16",
- "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
- "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
- "license": "MIT",
- "engines": {
- "node": ">=12.0"
- }
- },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -4373,6 +4367,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5518,6 +5513,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5765,6 +5761,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -6791,6 +6788,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9058,6 +9056,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-stream-zip": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz",
+ "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/antelle"
+ }
+ },
"node_modules/nodemailer": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
@@ -9555,6 +9566,7 @@
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
@@ -10621,6 +10633,7 @@
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"parseley": "~0.13.1"
},
diff --git a/backend/package.json b/backend/package.json
index 5ac0ea74..9bac64d0 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -17,7 +17,6 @@
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
- "adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "1.15.2",
"bcrypt": "6.0.0",
@@ -45,6 +44,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
+ "node-stream-zip": "^1.15.0",
"nodemailer": "^8.0.5",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
diff --git a/backend/server.js b/backend/server.js
index 1f1e0966..7709e394 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -639,6 +639,7 @@ app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/admin/system', require('./src/routes/adminSystem'));
app.use('/api/admin/feature-flags', require('./src/routes/adminFeatureFlags'));
+app.use('/api/admin/whatsapp', require('./src/routes/adminWhatsapp'));
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
@@ -842,6 +843,15 @@ async function startServer() {
}
startEmailQueueProcessor();
+ // Start WhatsApp queue processor — no-ops each cycle unless the
+ // `whatsapp` flag is on and a config exists (migration 136, #640D).
+ try {
+ const { startWhatsAppQueueProcessor } = require('./src/services/whatsappProcessor');
+ startWhatsAppQueueProcessor();
+ } catch (err) {
+ logger.warn('WhatsApp queue processor start failed:', err.message);
+ }
+
// Start incoming-mail (IMAP) poller — no-ops each minute unless the
// `incomingMail` flag is on and a mailbox is configured (migration 128).
try {
diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js
index d290e26e..b9735550 100644
--- a/backend/src/routes/adminArchives.js
+++ b/backend/src/routes/adminArchives.js
@@ -7,7 +7,7 @@ const { slugify } = require('../utils/slug');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
-const AdmZip = require('adm-zip');
+const StreamZip = require('node-stream-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const router = express.Router();
@@ -167,32 +167,62 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
// Extract the archive
try {
- const zip = new AdmZip(fullArchivePath);
+ // node-stream-zip streams each entry to disk on extract — adm-zip used
+ // to load the whole archive into a Node Buffer up front, which capped
+ // restore at 2 GiB (ERR_FS_FILE_TOO_LARGE). Real-world wedding archives
+ // routinely cross that line. Credit: 8digit/picpeak@69033c6.
+ const zip = new StreamZip.async({ file: fullArchivePath });
const eventsDir = path.join(storagePath, 'events/active');
const eventDir = path.join(eventsDir, archive.slug);
-
+
// Create event directory if it doesn't exist
await fs.mkdir(eventDir, { recursive: true });
-
+
// Log ZIP contents for debugging
console.log(`Extracting archive to: ${eventDir}`);
- const entries = zip.getEntries();
+ const entries = Object.values(await zip.entries());
console.log(`Archive contains ${entries.length} entries`);
-
- // Extract files to the event directory
- zip.extractAllTo(eventDir, true);
-
+
+ // Stream-extract everything to disk
+ await zip.extract(null, eventDir);
+ await zip.close();
+
+ // Load photos manifest if present. The gallery filenames are renamed on
+ // upload, so `original_filename` (and category linkage) can't be derived
+ // from the extracted files alone — they're only recoverable from the
+ // manifest the archive process writes. Older archives have no manifest;
+ // we fall back to filename for those.
+ const manifestByFilename = new Map();
+ try {
+ const manifestRaw = await fs.readFile(
+ path.join(eventDir, 'photos_manifest.json'), 'utf8',
+ );
+ const parsed = JSON.parse(manifestRaw);
+ if (Array.isArray(parsed)) {
+ for (const m of parsed) {
+ if (m && m.filename) manifestByFilename.set(m.filename, m);
+ }
+ }
+ console.log(`Loaded photos manifest: ${manifestByFilename.size} entries`);
+ } catch (e) {
+ if (e.code !== 'ENOENT') {
+ console.warn('Photos manifest present but unreadable; falling back to filenames', e.message);
+ } else {
+ console.log('No photos manifest in archive (older archive); original_filename falls back to filename');
+ }
+ }
+
// Get list of extracted files to update database
const extractedPhotos = [];
-
+
// First, collect all category information from the ZIP structure
const categoriesMap = new Map();
-
+
for (const entry of entries) {
- if (!entry.isDirectory && entry.entryName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
- const filename = path.basename(entry.entryName);
- const dirPath = path.dirname(entry.entryName);
- const actualFilePath = path.join(eventDir, entry.entryName);
+ if (!entry.isDirectory && entry.name.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
+ const filename = path.basename(entry.name);
+ const dirPath = path.dirname(entry.name);
+ const actualFilePath = path.join(eventDir, entry.name);
try {
// Check if file was extracted successfully
@@ -239,10 +269,14 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
if (!existingPhoto) {
// Store relative path from storage root
const relativePath = path.relative(storagePath, actualFilePath);
+ const manifestEntry = manifestByFilename.get(filename);
extractedPhotos.push({
event_id: archive.id,
filename: filename,
- original_filename: filename,
+ // Recover original_filename from the manifest if present;
+ // legacy archives without a manifest lose nothing (filename
+ // is what they had before).
+ original_filename: manifestEntry?.original_filename || filename,
path: relativePath,
thumbnail_path: null, // Will be regenerated by thumbnail service
type: path.extname(filename).substring(1).toLowerCase(),
@@ -253,7 +287,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
}
} catch (statError) {
console.error(`Failed to stat file: ${actualFilePath}`);
- console.error(`Entry name was: ${entry.entryName}`);
+ console.error(`Entry name was: ${entry.name}`);
console.error('Error:', statError.message);
// Skip this file if we can't stat it
continue;
diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js
index 84a162a9..55a18b25 100644
--- a/backend/src/routes/adminCategories.js
+++ b/backend/src/routes/adminCategories.js
@@ -112,7 +112,8 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
- }).withMessage('hero_photo_id must be an integer or null')
+ }).withMessage('hero_photo_id must be an integer or null'),
+ body('allow_downloads').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -144,6 +145,11 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
updateData.hero_photo_id = hero_photo_id || null;
}
+ // Per-category download permission (#640). AND with event-level allow_downloads.
+ if (Object.prototype.hasOwnProperty.call(req.body, 'allow_downloads')) {
+ updateData.allow_downloads = req.body.allow_downloads;
+ }
+
await db('photo_categories')
.where('id', id)
.update(updateData);
diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js
index 3054ed80..fc880c6b 100644
--- a/backend/src/routes/adminEvents.js
+++ b/backend/src/routes/adminEvents.js
@@ -836,6 +836,29 @@ router.post('/', adminAuth, requirePermission('events.create'), [
});
}
+ // WhatsApp gallery_ready notification (#640D). Fires when the event is
+ // created NOT as a draft, the `whatsapp` flag is on, a config exists, and
+ // the customer supplied a phone number. Non-fatal: a queue failure should
+ // never block gallery creation.
+ if (!isDraft && customerPhone) {
+ try {
+ const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
+ const waConfig = await getWhatsAppConfig();
+ if (waConfig && waConfig.enabled) {
+ await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
+ customer_name: customerName || '',
+ event_name,
+ gallery_link: shareUrl,
+ gallery_password: requirePassword ? password : '',
+ expiry_date: expires_at ? expires_at.toISOString() : null,
+ language: null, // resolved by processor via general_default_language
+ });
+ }
+ } catch (waError) {
+ logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message });
+ }
+ }
+
// Fire event.published when the event is created NOT as a draft. The
// separate /publish endpoint fires it for the draft → live transition;
// this covers the "create-and-publish in one shot" path.
@@ -1142,6 +1165,34 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
});
}
+ // WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery
+ // dialog (#627) hands us the password back so we can deliver it via
+ // WhatsApp as well. Uses customer_phone from the persisted event row.
+ if (event.customer_phone) {
+ try {
+ const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
+ const waConfig = await getWhatsAppConfig();
+ if (waConfig && waConfig.enabled) {
+ const { shareUrl: shareUrlForWa } = await buildShareLinkVariants({
+ slug: event.slug, shareToken: event.share_token,
+ });
+ await queueWhatsapp(parseInt(id, 10), event.customer_phone, 'gallery_created', {
+ customer_name: event.customer_name || event.host_name || '',
+ event_name: event.event_name,
+ gallery_link: shareUrlForWa || `${await getFrontendBaseUrl()}/gallery/${event.slug}`,
+ // Plaintext only when the admin re-typed at publish; otherwise
+ // omit so the buildComponents() helper renders an empty {{4}}
+ // line instead of leaking the "(set at creation)" sentinel.
+ gallery_password: requirePassword && password ? password : '',
+ expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
+ language: null, // resolved by processor via general_default_language
+ });
+ }
+ } catch (waError) {
+ logger.warn('Failed to queue WhatsApp notification on publish', { error: waError.message });
+ }
+ }
+
await logActivity('event_published',
{ event_name: event.event_name },
id,
diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js
index 68b3f775..6464aa24 100644
--- a/backend/src/routes/adminFeatureFlags.js
+++ b/backend/src/routes/adminFeatureFlags.js
@@ -80,6 +80,10 @@ const KNOWN_FLAGS = [
// the Project Overview cockpit ("book to project" hours control, 360°
// rollup feed). Lights up the Clients section. Customers never see it.
'projects',
+ // WhatsApp Business API delivery channel (migration 136, #640D). Strictly
+ // opt-in — operators must register a Meta-approved template before turning
+ // it on. Independent of email; both can fire on the same event.
+ 'whatsapp',
];
// Spec defaults for any flag missing from the DB (e.g. a row added by a
@@ -107,6 +111,7 @@ const DEFAULT_FLAGS = {
incomingInvoices: false,
expenses: false,
projects: false,
+ whatsapp: false,
};
async function readAllFlags() {
diff --git a/backend/src/routes/adminFeedback.js b/backend/src/routes/adminFeedback.js
index 03cad49a..6519a8a1 100644
--- a/backend/src/routes/adminFeedback.js
+++ b/backend/src/routes/adminFeedback.js
@@ -315,15 +315,21 @@ router.get('/events/:eventId/feedback/export',
async (req, res) => {
try {
const { eventId } = req.params;
- const { format = 'json' } = req.query;
-
- const feedback = await feedbackService.exportEventFeedback(eventId);
-
+ const { format = 'json', shape = 'long' } = req.query;
+
+ // shape='long' (default, backward-compat) → one row per individual
+ // feedback action. shape='pivot' (#640 part #6) → one row per
+ // (photo, guest) with is_favorited / is_liked / star_rating / comment.
+ const isPivot = String(shape).toLowerCase() === 'pivot';
+ const feedback = isPivot
+ ? await feedbackService.exportEventFeedbackPivoted(eventId)
+ : await feedbackService.exportEventFeedback(eventId);
+
if (format === 'csv') {
- // Convert to CSV
const csv = convertToCSV(feedback);
+ const fileSuffix = isPivot ? 'pivot' : 'long';
res.setHeader('Content-Type', 'text/csv');
- res.setHeader('Content-Disposition', `attachment; filename="feedback-${eventId}.csv"`);
+ res.setHeader('Content-Disposition', `attachment; filename="feedback-${fileSuffix}-${eventId}.csv"`);
res.send(csv);
} else {
res.json(feedback);
@@ -428,24 +434,30 @@ router.delete('/word-filters/:id',
}
);
-// Helper function to convert JSON to CSV
+// Helper function to convert JSON to CSV. Improvements over the original
+// (#640 part #6): handles booleans (rendered yes/no for spreadsheet
+// readability), nulls/undefined (rendered as empty), and escapes strings
+// containing newlines as well as commas/quotes — comments with line breaks
+// were silently breaking the CSV row count before this.
function convertToCSV(data) {
if (!data || data.length === 0) return '';
-
+
const headers = Object.keys(data[0]);
const csvHeaders = headers.join(',');
-
+
const csvRows = data.map(row => {
return headers.map(header => {
const value = row[header];
- // Escape quotes and wrap in quotes if contains comma
- if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
+ if (value === null || value === undefined) return '';
+ if (typeof value === 'boolean') return value ? 'yes' : 'no';
+ if (typeof value === 'string'
+ && (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r'))) {
return `"${value.replace(/"/g, '""')}"`;
}
- return value || '';
+ return value;
}).join(',');
});
-
+
return [csvHeaders, ...csvRows].join('\n');
}
diff --git a/backend/src/routes/adminWhatsapp.js b/backend/src/routes/adminWhatsapp.js
new file mode 100644
index 00000000..411dbd3f
--- /dev/null
+++ b/backend/src/routes/adminWhatsapp.js
@@ -0,0 +1,149 @@
+'use strict';
+
+/**
+ * Admin WhatsApp configuration routes (#640 part D).
+ *
+ * GET /api/admin/whatsapp/config — returns config with access_token masked
+ * PUT /api/admin/whatsapp/config — upsert; masked tokens preserved
+ * POST /api/admin/whatsapp/test — send a static test message to verify the
+ * Meta credentials + template approval
+ *
+ * Ported from filpgame/picpeak with a feature-flag gate via
+ * `requireFeatureFlag('whatsapp')` and tighter validation on the enable path
+ * (Phone Number ID, template name, AND access token all required to flip
+ * `enabled=true`).
+ */
+
+const express = require('express');
+const router = express.Router();
+const { db, logActivity } = require('../database/db');
+const { adminAuth } = require('../middleware/auth');
+const { requirePermission } = require('../middleware/permissions');
+const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
+const { sendWhatsAppMessage } = require('../services/whatsappService');
+const logger = require('../utils/logger');
+
+// Gate everything behind the feature flag — operators who haven't enabled
+// WhatsApp shouldn't see the routes (matches the accounting / contracts
+// pattern). The Settings UI hides the tab as well; this is defence in depth.
+router.use(requireFeatureFlag('whatsapp'));
+
+router.get('/config', adminAuth, requirePermission('settings.view'), async (req, res) => {
+ try {
+ const config = await db('whatsapp_configs').first();
+ if (!config) {
+ return res.json({
+ phone_number_id: '',
+ waba_id: '',
+ access_token: '',
+ template_name: 'gallery_ready',
+ enabled: false,
+ });
+ }
+ res.json({
+ phone_number_id: config.phone_number_id,
+ waba_id: config.waba_id,
+ access_token: config.access_token ? '********' : '',
+ template_name: config.template_name,
+ enabled: Boolean(config.enabled),
+ });
+ } catch (error) {
+ logger.error('GET whatsapp-config error:', error);
+ res.status(500).json({ error: 'Failed to load WhatsApp configuration' });
+ }
+});
+
+router.put('/config', adminAuth, requirePermission('settings.edit'), async (req, res) => {
+ try {
+ const { phone_number_id, waba_id, access_token, template_name, enabled } = req.body;
+
+ const existing = await db('whatsapp_configs').first();
+ const isEnabled = Boolean(enabled);
+
+ const data = {
+ phone_number_id: phone_number_id || '',
+ waba_id: waba_id || '',
+ template_name: template_name || 'gallery_ready',
+ enabled: isEnabled,
+ updated_at: new Date(),
+ };
+
+ // Only persist the access_token when a real value (not the masked sentinel)
+ // is supplied. This lets the admin PATCH everything else without re-entering
+ // their long-lived Meta token every time.
+ const hasNewToken = access_token && access_token !== '********';
+ const hasStoredToken = existing && Boolean(existing.access_token);
+
+ if (hasNewToken) {
+ data.access_token = access_token;
+ } else if (!existing && !hasNewToken) {
+ // First-time insert without a real token — reject so we never store an
+ // unusable enabled=true config.
+ return res.status(400).json({ error: 'Access token is required when saving a new configuration' });
+ }
+
+ if (isEnabled) {
+ if (!data.phone_number_id) {
+ return res.status(400).json({ error: 'Phone Number ID is required to enable WhatsApp' });
+ }
+ if (!data.template_name) {
+ return res.status(400).json({ error: 'Template name is required to enable WhatsApp' });
+ }
+ if (!hasNewToken && !hasStoredToken) {
+ return res.status(400).json({ error: 'Access token is required to enable WhatsApp' });
+ }
+ }
+
+ if (existing) {
+ await db('whatsapp_configs').where('id', existing.id).update(data);
+ } else {
+ if (!data.access_token) data.access_token = '';
+ await db('whatsapp_configs').insert(data);
+ }
+
+ await logActivity(
+ 'whatsapp_config_updated',
+ { phone_number_id, enabled: isEnabled },
+ null,
+ { type: 'admin', id: req.admin.id, name: req.admin.username },
+ );
+
+ res.json({ success: true });
+ } catch (error) {
+ logger.error('PUT whatsapp-config error:', error);
+ res.status(500).json({ error: 'Failed to save WhatsApp configuration' });
+ }
+});
+
+router.post('/test', adminAuth, requirePermission('settings.edit'), async (req, res) => {
+ try {
+ const { phone } = req.body;
+ if (!phone) {
+ return res.status(400).json({ error: 'Phone number is required' });
+ }
+
+ const config = await db('whatsapp_configs').first();
+ if (!config || !config.phone_number_id || !config.access_token) {
+ return res.status(400).json({ error: 'WhatsApp is not configured' });
+ }
+
+ // Static template parameters — the admin only needs to confirm that the
+ // configured Meta credentials + approved template can deliver to a real
+ // phone, not the per-event substitution logic.
+ const testComponents = [
+ 'PicPeak Test',
+ 'Test Gallery',
+ 'https://example.com/gallery/test',
+ '',
+ '',
+ ];
+
+ const result = await sendWhatsAppMessage(phone, config, 'en_US', testComponents);
+ res.json({ success: true, messageId: result.messageId });
+ } catch (error) {
+ logger.error('WhatsApp test send error:', error);
+ res.status(500).json({ error: error.message || 'Failed to send test message' });
+ }
+});
+
+module.exports = router;
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index 31bd918b..0ea362e0 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -396,7 +396,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
if (usedCategoryIds.length > 0) {
const categoryDetails = await db('photo_categories')
.whereIn('id', usedCategoryIds)
- .select('id', 'name', 'slug', 'is_global', 'hero_photo_id')
+ .select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads')
.orderBy('name', 'asc');
categories = categoryDetails.map(cat => ({
@@ -404,7 +404,11 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
name: cat.name,
slug: cat.slug,
is_global: cat.is_global,
- hero_photo_id: cat.hero_photo_id || null
+ hero_photo_id: cat.hero_photo_id || null,
+ // Per-category download flag (#640). false explicitly disables; the
+ // gallery hides the download button. Defaults true so categories
+ // created before migration 135 keep working.
+ allow_downloads: cat.allow_downloads !== false
}));
}
@@ -530,6 +534,11 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
type: photo.type,
category_id: photo.category_id || null,
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
+ // Per-category download permission (#640). Defaults true for photos
+ // without a category or for categories that pre-date migration 135.
+ category_allow_downloads: photo.category_id && categoryMap[photo.category_id]
+ ? categoryMap[photo.category_id].allow_downloads !== false
+ : true,
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
@@ -650,6 +659,18 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
return res.status(403).json({ error: 'Photo not available' });
}
+ // Per-category download permission (#640). Photos without a category are
+ // always downloadable when the event allows downloads — only categorised
+ // photos can opt out per-category.
+ if (photo.category_id) {
+ const cat = await db('photo_categories')
+ .where('id', photo.category_id)
+ .first('allow_downloads');
+ if (cat && cat.allow_downloads === false) {
+ return res.status(403).json({ error: 'Downloads are disabled for this category' });
+ }
+ }
+
// Update download count
await db('photos').where('id', photoId).increment('download_count', 1);
@@ -797,9 +818,17 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
);
- // Fetch photos
+ // Fetch photos — exclude photos in categories that disabled downloads (#640).
+ // Uncategorised photos are always included; categories without the column
+ // (pre-migration-135) fall through the LEFT JOIN's null and are included.
const photos = await db('photos')
+ .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
+ .where(function () {
+ this.whereNull('photos.category_id')
+ .orWhere('photo_categories.allow_downloads', true)
+ .orWhereNull('photo_categories.allow_downloads');
+ })
.select('photos.*')
.orderBy('photos.type', 'asc')
.orderBy('photos.uploaded_at', 'desc');
@@ -926,10 +955,17 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
return res.status(400).json({ error: 'No valid photo IDs provided' });
}
- // Fetch photos
+ // Fetch photos — exclude photos in categories that disabled downloads (#640).
+ // Same LEFT JOIN pattern as the download-all endpoint.
const photos = await db('photos')
+ .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.whereIn('photos.id', photoIds)
+ .where(function () {
+ this.whereNull('photos.category_id')
+ .orWhere('photo_categories.allow_downloads', true)
+ .orWhereNull('photo_categories.allow_downloads');
+ })
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js
index 9c3ef978..1d411f3e 100644
--- a/backend/src/services/archiveService.js
+++ b/backend/src/services/archiveService.js
@@ -26,6 +26,36 @@ async function archiveEvent(event) {
const tmpArchive = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-${archiveName}`);
try {
+ // Photos manifest — the gallery filenames are renamed on upload, so
+ // `original_filename` (and category linkage) can't be derived from the
+ // extracted files alone. Persisting a manifest inside the archive lets a
+ // future restore round-trip recover those fields. Falls back to bare
+ // filename for archives produced before this lands (see restore path).
+ let photosManifestEntry = null;
+ try {
+ const manifestRows = await db('photos')
+ .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
+ .where('photos.event_id', event.id)
+ .select(
+ 'photos.filename',
+ 'photos.original_filename',
+ 'photos.type',
+ 'photos.uploaded_at',
+ 'photo_categories.name as category_name',
+ );
+ if (manifestRows.length > 0) {
+ photosManifestEntry = {
+ name: 'photos_manifest.json',
+ buffer: Buffer.from(JSON.stringify(manifestRows, null, 2), 'utf8'),
+ };
+ logger.info(`Photos manifest prepared: ${manifestRows.length} entries`);
+ }
+ } catch (error) {
+ logger.error(`Error building photos manifest for event ${event.slug}:`, error);
+ // Non-fatal — restore will fall back to filename as original_filename
+ // for events archived without a manifest, same as the legacy behaviour.
+ }
+
// Collect feedback data first so it can be included as in-memory entries.
const feedbackEntries = [];
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
@@ -116,6 +146,9 @@ async function archiveEvent(event) {
for (const f of feedbackEntries) {
archive.append(f.buffer, { name: f.name });
}
+ if (photosManifestEntry) {
+ archive.append(photosManifestEntry.buffer, { name: photosManifestEntry.name });
+ }
archive.finalize();
};
diff --git a/backend/src/services/feedbackService.js b/backend/src/services/feedbackService.js
index 0e8c3a05..f2c018d1 100644
--- a/backend/src/services/feedbackService.js
+++ b/backend/src/services/feedbackService.js
@@ -377,7 +377,9 @@ class FeedbackService {
}
/**
- * Export feedback data for an event
+ * Export feedback data for an event — long-form (one row per individual
+ * feedback action: favourite, like, rating, or comment). Backward-compatible
+ * with archives and any external integrations that consume the existing CSV.
*/
async exportEventFeedback(eventId) {
try {
@@ -395,7 +397,7 @@ class FeedbackService {
)
.orderBy('photos.filename')
.orderBy('photo_feedback.created_at');
-
+
return feedback;
} catch (error) {
logger.error('Error exporting feedback:', error);
@@ -403,6 +405,97 @@ class FeedbackService {
}
}
+ /**
+ * Export feedback data for an event — pivoted (one row per
+ * (filename, guest_identifier) pair, columns: is_favorited, is_liked,
+ * star_rating, comment, latest_at). Useful for spreadsheet pivot tables
+ * and per-guest engagement scans. Hidden-by-moderator rows are excluded
+ * because the pivot represents "what the guest currently sees / what we
+ * want to surface" rather than the raw event log.
+ *
+ * Returns the same shape regardless of database driver — pivot is built
+ * in JS so Postgres / SQLite behave identically. Ported from
+ * 8digit/picpeak@ed7943b (#640 part #6).
+ */
+ async exportEventFeedbackPivoted(eventId) {
+ try {
+ const rows = await db('photo_feedback')
+ .join('photos', 'photo_feedback.photo_id', 'photos.id')
+ .where('photo_feedback.event_id', eventId)
+ .where('photo_feedback.is_hidden', false)
+ .select(
+ 'photos.filename',
+ 'photo_feedback.feedback_type',
+ 'photo_feedback.rating',
+ 'photo_feedback.comment_text',
+ 'photo_feedback.guest_name',
+ 'photo_feedback.guest_email',
+ 'photo_feedback.guest_identifier',
+ 'photo_feedback.created_at'
+ )
+ .orderBy('photos.filename')
+ .orderBy('photo_feedback.guest_identifier');
+
+ const byKey = new Map();
+ for (const row of rows) {
+ // Key needs both the photo and the guest. Anonymous feedback (no
+ // identifier) gets a synthetic key per row so two anonymous guests'
+ // actions on the same photo don't collapse together.
+ const guestKey = row.guest_identifier || `anon-${row.created_at}`;
+ const key = `${row.filename}::${guestKey}`;
+ let entry = byKey.get(key);
+ if (!entry) {
+ entry = {
+ filename: row.filename,
+ guest_name: row.guest_name || '',
+ guest_email: row.guest_email || '',
+ is_favorited: false,
+ is_liked: false,
+ star_rating: '',
+ comment: '',
+ latest_at: row.created_at,
+ };
+ byKey.set(key, entry);
+ }
+ // Prefer non-empty contact fields if any row supplied them.
+ if (!entry.guest_name && row.guest_name) entry.guest_name = row.guest_name;
+ if (!entry.guest_email && row.guest_email) entry.guest_email = row.guest_email;
+
+ switch (row.feedback_type) {
+ case 'favorite':
+ entry.is_favorited = true;
+ break;
+ case 'like':
+ entry.is_liked = true;
+ break;
+ case 'rating':
+ if (row.rating != null) entry.star_rating = row.rating;
+ break;
+ case 'comment':
+ if (row.comment_text) {
+ // Most recent comment wins. Older comments from the same guest
+ // on the same photo are dropped — the export is "current state",
+ // not the comment history.
+ entry.comment = row.comment_text;
+ }
+ break;
+ default:
+ // Unknown feedback type — ignore so a future type doesn't break the export.
+ break;
+ }
+ // Track the latest action timestamp across all feedback types.
+ if (row.created_at && entry.latest_at && row.created_at > entry.latest_at) {
+ entry.latest_at = row.created_at;
+ }
+ }
+
+ return Array.from(byKey.values());
+ } catch (error) {
+ logger.error('Error exporting feedback (pivoted):', error);
+ throw error;
+ }
+ }
+
/**
* Get filtered photos based on feedback criteria
* @param {number} eventId - Event ID
diff --git a/backend/src/services/whatsappProcessor.js b/backend/src/services/whatsappProcessor.js
new file mode 100644
index 00000000..b2796273
--- /dev/null
+++ b/backend/src/services/whatsappProcessor.js
@@ -0,0 +1,259 @@
+'use strict';
+
+/**
+ * WhatsApp queue processor (#640 part D).
+ *
+ * Polls `whatsapp_queue` every 30s. For each pending row whose retry_count <
+ * 3, builds the Meta template components from the stored message_data,
+ * resolves the language code, and sends via whatsappService. Transient
+ * failures bump retry_count; permanent failures mark the row 'failed'.
+ *
+ * Ported from filpgame/picpeak with the following changes:
+ * - Default language sourced from `app_settings.general_default_language`
+ * (matches our email-language resolution pattern) instead of a hardcoded
+ * `pt_BR`. Falls back to `en` then `en_US` if nothing is configured.
+ * - Cycle size + interval pulled from env vars so low-volume installs can
+ * dial back the poll frequency.
+ * - Exits gracefully when the `whatsapp` feature flag is off (no config
+ * polling, no queue queries).
+ */
+
+const { db } = require('../database/db');
+const logger = require('../utils/logger');
+const { sendWhatsAppMessage } = require('./whatsappService');
+
+// IETF language tag (with hyphen or underscore) → Meta template language code.
+// Meta template languages: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/supported-languages
+const LANGUAGE_MAP = {
+ en: 'en_US', 'en-us': 'en_US', 'en_us': 'en_US',
+ de: 'de_DE', 'de-de': 'de_DE', 'de_de': 'de_DE',
+ pt: 'pt_BR', ptbr: 'pt_BR', 'pt-br': 'pt_BR', 'pt_br': 'pt_BR',
+ ru: 'ru_RU', 'ru-ru': 'ru_RU', 'ru_ru': 'ru_RU',
+ nl: 'nl_NL', 'nl-nl': 'nl_NL', 'nl_nl': 'nl_NL',
+ fr: 'fr_FR', 'fr-fr': 'fr_FR', 'fr_fr': 'fr_FR',
+ es: 'es_ES', 'es-es': 'es_ES', 'es_es': 'es_ES',
+ it: 'it_IT', 'it-it': 'it_IT', 'it_it': 'it_IT',
+};
+
+// Per-locale label embedded in the {{4}} password line. Meta templates only
+// accept positional parameters in the body, so the "Password:" prefix has to
+// be baked into the parameter itself rather than living in the template body.
+const PASSWORD_LABELS = {
+ pt_BR: '🔒 Senha',
+ en_US: '🔒 Password',
+ de_DE: '🔒 Passwort',
+ ru_RU: '🔒 Пароль',
+ nl_NL: '🔒 Wachtwoord',
+ fr_FR: '🔒 Mot de passe',
+ es_ES: '🔒 Contraseña',
+ it_IT: '🔒 Password',
+};
+
+const INTL_LOCALE_MAP = {
+ pt_BR: 'pt-BR', en_US: 'en-US', de_DE: 'de-DE',
+ ru_RU: 'ru-RU', nl_NL: 'nl-NL', fr_FR: 'fr-FR',
+ es_ES: 'es-ES', it_IT: 'it-IT',
+};
+
+const POLL_INTERVAL_MS = parseInt(process.env.WHATSAPP_QUEUE_POLL_MS || '30000', 10);
+const CYCLE_BATCH_SIZE = parseInt(process.env.WHATSAPP_QUEUE_BATCH || '10', 10);
+const MAX_RETRIES = 3;
+
+let pollHandle = null;
+
+/**
+ * Resolve a Meta template language code from whatever's in the message_data
+ * (admin-set per-event language) or the system default.
+ */
+function resolveLanguageCode(lang) {
+ if (!lang) return null; // signal: caller should fall through to default
+ const normalised = String(lang).toLowerCase().replace(/-/g, '_');
+ return LANGUAGE_MAP[normalised] || null;
+}
+
+async function getSystemDefaultLanguageCode() {
+ try {
+ const row = await db('app_settings')
+ .where('setting_key', 'general_default_language')
+ .first();
+ if (row && row.setting_value) {
+ let lang = row.setting_value;
+ try { lang = JSON.parse(lang); } catch (_) { /* not JSON, use raw */ }
+ const resolved = resolveLanguageCode(typeof lang === 'string' ? lang.trim() : '');
+ if (resolved) return resolved;
+ }
+ } catch (error) {
+ logger.debug('whatsappProcessor: general_default_language read failed', { error: error.message });
+ }
+ return 'en_US';
+}
+
+function formatDate(raw, metaLangCode) {
+ if (!raw) return '';
+ try {
+ const d = new Date(raw);
+ if (Number.isNaN(d.getTime())) return '';
+ const intlLocale = INTL_LOCALE_MAP[metaLangCode] || 'en-US';
+ return d.toLocaleDateString(intlLocale, { day: '2-digit', month: '2-digit', year: 'numeric' });
+ } catch {
+ return '';
+ }
+}
+
+/**
+ * Build the positional body components for the configured template. The
+ * default `gallery_ready` template (operator-registered) expects:
+ * {{1}} customer_name
+ * {{2}} event_name
+ * {{3}} gallery_link
+ * {{4}} password line (with localised "🔒 Password:" prefix, or empty)
+ * {{5}} expiry date (or empty)
+ */
+function buildComponents(data, metaLang) {
+ const label = PASSWORD_LABELS[metaLang] || PASSWORD_LABELS.en_US;
+ const hasRealPassword = data.gallery_password
+ && data.gallery_password !== 'No password required'
+ && data.gallery_password !== '(set at creation)';
+ const passwordLine = hasRealPassword ? `${label}: ${data.gallery_password}` : '';
+ const expiryLine = formatDate(data.expiry_date, metaLang);
+
+ return [
+ data.customer_name || '',
+ data.event_name || '',
+ data.gallery_link || '',
+ passwordLine,
+ expiryLine,
+ ];
+}
+
+async function getWhatsAppConfig() {
+ try {
+ return await db('whatsapp_configs').first();
+ } catch (error) {
+ logger.debug('whatsappProcessor: failed to read whatsapp_configs', { error: error.message });
+ return null;
+ }
+}
+
+/**
+ * Enqueue a WhatsApp message. Safe to call without checking the feature flag
+ * upstream — the processor's poll loop is the gate, so a queued message just
+ * sits idle if the flag is off. Routes still SHOULD check the flag before
+ * calling so the customer-facing error path (silent feature disabled vs. real
+ * queue failure) stays distinguishable.
+ */
+async function queueWhatsapp(eventId, recipientPhone, messageType, messageData) {
+ try {
+ await db('whatsapp_queue').insert({
+ event_id: eventId,
+ recipient_phone: recipientPhone,
+ message_type: messageType,
+ message_data: JSON.stringify(messageData || {}),
+ status: 'pending',
+ retry_count: 0,
+ created_at: new Date(),
+ });
+ logger.info(`WhatsApp queued: ${messageType} → ${recipientPhone}`);
+ } catch (error) {
+ logger.error('Error queueing WhatsApp message:', error);
+ throw error;
+ }
+}
+
+/**
+ * One poll cycle. Reads up to CYCLE_BATCH_SIZE pending rows, sends each, and
+ * updates retry/error/status fields. Wraps everything in defensive try/catch
+ * so a single bad row can't stall the rest of the batch.
+ */
+async function processWhatsAppQueue() {
+ let config;
+ try {
+ config = await getWhatsAppConfig();
+ } catch (e) {
+ // Tables not migrated yet — just bail.
+ return;
+ }
+ if (!config || !config.enabled || !config.phone_number_id || !config.access_token) return;
+
+ const defaultLanguage = await getSystemDefaultLanguageCode();
+
+ let pending;
+ try {
+ pending = await db('whatsapp_queue')
+ .where('status', 'pending')
+ .andWhere('retry_count', '<', MAX_RETRIES)
+ .orderBy('created_at', 'asc')
+ .limit(CYCLE_BATCH_SIZE);
+ } catch (error) {
+ logger.error('WhatsApp queue: failed to query pending rows', { error: error.message });
+ return;
+ }
+
+ if (pending.length === 0) return;
+
+ logger.info(`WhatsApp queue: processing ${pending.length} message(s)`);
+
+ for (const item of pending) {
+ try {
+ const data = typeof item.message_data === 'string'
+ ? JSON.parse(item.message_data || '{}')
+ : item.message_data || {};
+
+ const requestedLang = resolveLanguageCode(data.language);
+ const metaLang = requestedLang || defaultLanguage;
+ const components = buildComponents(data, metaLang);
+
+ await sendWhatsAppMessage(item.recipient_phone, config, metaLang, components);
+
+ await db('whatsapp_queue')
+ .where('id', item.id)
+ .update({ status: 'sent', sent_at: new Date(), error_message: null });
+ } catch (error) {
+ const newRetryCount = (item.retry_count || 0) + 1;
+ const exhausted = newRetryCount >= MAX_RETRIES;
+ await db('whatsapp_queue')
+ .where('id', item.id)
+ .update({
+ retry_count: newRetryCount,
+ error_message: String(error.message).slice(0, 2000),
+ ...(exhausted ? { status: 'failed' } : {}),
+ });
+ logger.error(
+ `WhatsApp message ${item.id} ${exhausted ? 'failed (permanent)' : `retry ${newRetryCount}/${MAX_RETRIES}`}:`,
+ error.message,
+ );
+ }
+ }
+}
+
+function startWhatsAppQueueProcessor() {
+ if (pollHandle) {
+ logger.info('WhatsApp queue processor already running — skipping start');
+ return;
+ }
+ // Fire once shortly after boot so the first message in a fresh install
+ // doesn't wait the full poll interval.
+ setTimeout(() => {
+ processWhatsAppQueue().catch((e) => logger.error('WhatsApp queue initial run failed', e));
+ }, 5000);
+ pollHandle = setInterval(() => {
+ processWhatsAppQueue().catch((e) => logger.error('WhatsApp queue cycle failed', e));
+ }, POLL_INTERVAL_MS);
+ logger.info(`WhatsApp queue processor started (poll every ${POLL_INTERVAL_MS}ms)`);
+}
+
+function stopWhatsAppQueueProcessor() {
+ if (pollHandle) {
+ clearInterval(pollHandle);
+ pollHandle = null;
+ logger.info('WhatsApp queue processor stopped');
+ }
+}
+
+module.exports = {
+ queueWhatsapp,
+ processWhatsAppQueue,
+ startWhatsAppQueueProcessor,
+ stopWhatsAppQueueProcessor,
+ getWhatsAppConfig,
+};
diff --git a/backend/src/services/whatsappService.js b/backend/src/services/whatsappService.js
new file mode 100644
index 00000000..c6db3339
--- /dev/null
+++ b/backend/src/services/whatsappService.js
@@ -0,0 +1,94 @@
+'use strict';
+
+/**
+ * WhatsApp Business API client (#640 part D).
+ *
+ * Thin wrapper over Meta Graph API for sending template messages. The
+ * processor is responsible for queueing + retries; this module is just the
+ * HTTP call. Ported from filpgame/picpeak with a few cleanups:
+ * - Meta API version bumped to v20 (filpgame was on v19, deprecated in Q3 2026).
+ * - Timeout dropped to 8s — Meta typically responds in <1s; 10s was too long
+ * for the processor's per-message budget at 10/cycle.
+ * - Error surfaces include the Meta `error.code` so the processor can decide
+ * between retryable transients and permanent failures (template not
+ * approved, recipient opted out, etc.).
+ */
+
+const axios = require('axios');
+const logger = require('../utils/logger');
+
+const META_API_VERSION = process.env.WHATSAPP_META_API_VERSION || 'v20.0';
+const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`;
+const REQUEST_TIMEOUT_MS = 8000;
+
+/**
+ * Normalise a phone number into Meta's expected `+E164` form.
+ * Strips non-digits, prepends `+`. Rejects clearly-invalid inputs early so
+ * the processor can mark the row permanently failed without a network call.
+ */
+function normalizePhone(phone) {
+ if (!phone) throw new Error('Invalid phone number: null or empty');
+ const digits = String(phone).replace(/\D/g, '');
+ if (digits.length < 10) {
+ throw new Error(`Invalid phone number: too short after normalisation (${phone})`);
+ }
+ return `+${digits}`;
+}
+
+/**
+ * Send one WhatsApp template message. `components` is an array of strings
+ * mapped into the template's positional {{1}}…{{N}} body parameters.
+ *
+ * Returns `{ messageId }` on success. Throws on any non-2xx — the processor
+ * catches and decides retry vs. fail based on the error code surfaced in the
+ * thrown message.
+ */
+async function sendWhatsAppMessage(recipientPhone, config, languageCode, components) {
+ const normalised = normalizePhone(recipientPhone);
+
+ const payload = {
+ messaging_product: 'whatsapp',
+ to: normalised,
+ type: 'template',
+ template: {
+ name: config.template_name,
+ language: { code: languageCode },
+ components: [
+ {
+ type: 'body',
+ parameters: components.map((text) => ({ type: 'text', text: String(text || '') })),
+ },
+ ],
+ },
+ };
+
+ try {
+ const response = await axios.post(
+ `${META_API_BASE}/${config.phone_number_id}/messages`,
+ payload,
+ {
+ headers: {
+ Authorization: `Bearer ${config.access_token}`,
+ 'Content-Type': 'application/json',
+ },
+ timeout: REQUEST_TIMEOUT_MS,
+ }
+ );
+ const messageId = response.data?.messages?.[0]?.id ?? 'unknown';
+ logger.info(`WhatsApp message sent: ${messageId} → ${normalised}`);
+ return { messageId };
+ } catch (error) {
+ const metaError = error.response?.data?.error;
+ const metaCode = metaError?.code;
+ const metaMessage = metaError?.message;
+ const composed = metaCode
+ ? `${metaMessage || error.message} (code=${metaCode})`
+ : (metaMessage || error.message);
+ logger.error('WhatsApp API error', {
+ error: composed, phone: normalised, code: metaCode,
+ });
+ throw new Error(composed);
+ }
+}
+
+module.exports = { normalizePhone, sendWhatsAppMessage };
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 95cd90cb..7fd92407 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -73,6 +73,7 @@ import { RequireFeature } from './components/admin/RequireFeature';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
+import { ConfirmDialogProvider } from './components/common';
import { usePublicSettings } from './hooks/usePublicSettings';
// Create a client
@@ -149,6 +150,7 @@ function App() {
+ {options.message}
+
+ {options.title}
+
+ )}
+