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() { + @@ -406,6 +408,7 @@ function App() { pauseOnHover theme={toastTheme} /> + diff --git a/frontend/src/components/admin/EventCategoryManager.tsx b/frontend/src/components/admin/EventCategoryManager.tsx index 791c47be..72845927 100644 --- a/frontend/src/components/admin/EventCategoryManager.tsx +++ b/frontend/src/components/admin/EventCategoryManager.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, X, Loader2, Image as ImageIcon, Check } from 'lucide-react'; +import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react'; import { toast } from 'react-toastify'; import { categoriesService, type PhotoCategory } from '../../services/categories.service'; import { photosService } from '../../services/photos.service'; @@ -79,6 +79,25 @@ export const EventCategoryManager: React.FC = ({ even }, }); + // Toggle per-category download permission (#640). The backend AND's this + // with the event-level `allow_downloads`, so disabling at either level + // blocks downloads for this category's photos. + const downloadToggleMutation = useMutation({ + mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) => + categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); + toast.success( + variables.allow + ? t('categories.downloadsEnabled', 'Downloads enabled for this category') + : t('categories.downloadsDisabled', 'Downloads disabled for this category') + ); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || t('categories.failedToToggleDownloads', 'Failed to update download permission')); + }, + }); + const handleCreate = () => { if (newCategoryName.trim()) { createMutation.mutate(newCategoryName.trim()); @@ -202,18 +221,49 @@ export const EventCategoryManager: React.FC = ({ even {category.name} - +
+ {/* Per-category downloads toggle (#640). Green DownloadCloud + icon when on, struck-through outline when off. The + event-level `allow_downloads` AND's with this — if the + whole event has downloads off, this toggle is cosmetic. */} + + +
); })} diff --git a/frontend/src/components/common/ConfirmDialog.tsx b/frontend/src/components/common/ConfirmDialog.tsx new file mode 100644 index 00000000..4530d8bd --- /dev/null +++ b/frontend/src/components/common/ConfirmDialog.tsx @@ -0,0 +1,182 @@ +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertCircle, AlertTriangle, X } from 'lucide-react'; +import { Button } from './Button'; +import { Card } from './Card'; + +/** + * Promise-based confirm dialog (#640 part C, ported from 8digit/picpeak@88bfde1). + * + * Replaces `window.confirm()` with a styled, themed, accessible in-app modal. + * Usage: + * + * const confirm = useConfirm(); + * const ok = await confirm({ + * title: 'Delete event?', + * message: 'This will permanently remove the gallery and all photos.', + * variant: 'danger', + * confirmLabel: 'Delete', + * }); + * if (ok) doDelete(); + * + * Wraps once at the App level via ; every component + * below it gets `useConfirm()` for free. Variants: + * - 'primary' (default) — plain confirm, no icon + * - 'danger' — red AlertCircle, red confirm button + * - 'warning' — amber AlertTriangle + * + * Keyboard: Escape cancels, Enter confirms, backdrop click cancels. The cancel + * button is focused by default so a stray Enter doesn't accidentally confirm a + * destructive action. + * + * This is the generic primitive. Existing inline-modal flows (PublishGalleryDialog, + * DuplicateEventDialog, PasswordResetModal, etc.) stay as-is — they collect + * structured input, not a simple yes/no. Call-site sweeps of `window.confirm()` + * follow in later PRs. + */ + +export type ConfirmVariant = 'primary' | 'danger' | 'warning'; + +export interface ConfirmOptions { + title?: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: ConfirmVariant; +} + +type Resolver = (value: boolean) => void; + +interface ConfirmContextValue { + confirm: (options: ConfirmOptions) => Promise; +} + +const ConfirmContext = createContext(null); + +export const useConfirm = (): ((options: ConfirmOptions) => Promise) => { + const ctx = useContext(ConfirmContext); + if (!ctx) { + throw new Error('useConfirm must be used within a ConfirmDialogProvider'); + } + return ctx.confirm; +}; + +export const ConfirmDialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { t } = useTranslation(); + const [options, setOptions] = useState(null); + const resolverRef = useRef(null); + const cancelButtonRef = useRef(null); + + const confirm = useCallback((opts: ConfirmOptions): Promise => { + return new Promise((resolve) => { + // If a prior confirm is still open (shouldn't happen in practice but + // guard anyway), resolve it as cancelled before opening the new one. + if (resolverRef.current) { + resolverRef.current(false); + } + resolverRef.current = resolve; + setOptions(opts); + }); + }, []); + + const settle = useCallback((value: boolean) => { + if (resolverRef.current) { + resolverRef.current(value); + resolverRef.current = null; + } + setOptions(null); + }, []); + + useEffect(() => { + if (!options) return; + cancelButtonRef.current?.focus(); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + settle(false); + } else if (e.key === 'Enter') { + // Don't hijack Enter when the focus is in an editable element — covers + // the (unusual) case where a confirm is open over an open input. + const tag = (document.activeElement as HTMLElement | null)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + e.preventDefault(); + settle(true); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [options, settle]); + + const variant = options?.variant ?? 'primary'; + const Icon = variant === 'danger' ? AlertCircle : variant === 'warning' ? AlertTriangle : null; + const iconClass = + variant === 'danger' + ? 'text-red-600 dark:text-red-400' + : variant === 'warning' + ? 'text-amber-600 dark:text-amber-400' + : ''; + + // Danger uses the outline button + an inline red override so the visual + // weight matches the action without redefining a Button variant for one case. + const confirmButtonVariant: 'primary' | 'outline' = variant === 'danger' ? 'outline' : 'primary'; + const confirmButtonClass = variant === 'danger' + ? 'bg-red-600 hover:bg-red-700 text-white border-red-600' + : ''; + + return ( + + {children} + {options && ( +
settle(false)} + role="dialog" + aria-modal="true" + > + e.stopPropagation()} + > +
+ {Icon && } +
+ {options.title && ( +

+ {options.title} +

+ )} +

+ {options.message} +

+
+ +
+ +
+ + +
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 9692c906..f68369aa 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -29,3 +29,4 @@ export { ProtectionWarning } from './ProtectionWarning'; export { ReCaptcha } from './ReCaptcha'; export { PasswordGenerator } from './PasswordGenerator'; export { MarkdownContent } from './MarkdownContent'; +export { ConfirmDialogProvider, useConfirm, type ConfirmOptions, type ConfirmVariant } from './ConfirmDialog'; diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 81daa7cf..e210be06 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -106,6 +106,11 @@ export const PhotoLightbox: React.FC = ({ // unsupported browsers fall through to a regular . const downloadPhotoMutation = useSavePhotoToDevice(); const currentPhoto = photos[currentIndex]; + // Per-category download permission (#640). AND'd with the event-level + // allowDownloads — disabling at either level hides the download button. + // Defaults true for uncategorised photos and pre-migration-135 categories. + const photoAllowsDownload = + allowDownloads && currentPhoto?.category_allow_downloads !== false; // DevTools protection - enabled by individual setting OR legacy protection level const devToolsEnabled = enableDevtoolsProtection || (useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')); @@ -171,7 +176,7 @@ export const PhotoLightbox: React.FC = ({ break; case 'd': case 'D': - if (allowDownloads) { + if (photoAllowsDownload) { handleDownload(); } break; @@ -353,7 +358,7 @@ export const PhotoLightbox: React.FC = ({ }; const handleDownload = () => { - if (!allowDownloads) return; + if (!photoAllowsDownload) return; downloadPhotoMutation.mutate({ slug, photoId: currentPhoto.id, @@ -680,7 +685,7 @@ export const PhotoLightbox: React.FC = ({
- {allowDownloads && ( + {photoAllowsDownload && ( + } + /> +

+ {t( + 'settings.whatsapp.accessTokenHint', + 'Stored masked as "********" on GET. Leave the masked value to keep the existing token; type a new one to replace it.', + )} +

+
+ +
+ + setTemplateName(e.target.value)} + placeholder="gallery_ready" + /> +

+ {t( + 'settings.whatsapp.templateNameHint', + 'Name of the Meta-approved message template. The default `gallery_ready` expects 5 body parameters: customer name, event name, gallery link, password line, expiry date. Approve the template in Meta Business Manager before enabling.', + )} +

+
+ + + + + + + + {/* Test send card — separate so the admin sees it as a distinct action, + not a sub-step of saving. */} + + +

+ {t('settings.whatsapp.testHeading', 'Send a test message')} +

+

+ {t( + 'settings.whatsapp.testHelp', + 'Sends a static template message to the phone number below to verify Meta credentials + template approval. Includes country code (e.g. +49…).', + )} +

+
+ setTestPhone(e.target.value)} + placeholder="+49123456789" + className="max-w-xs" + /> + +
+
+
+ + ); +}; + +export default WhatsAppTab; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 4bcf8b7f..a317244f 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -151,7 +151,8 @@ "preview": "Vorschau", "duplicate": "Duplizieren", "showAll": "Alle anzeigen", - "confirm": "Bestätigen" + "confirm": "Bestätigen", + "show": "Einblenden" }, "upload": { "photoCategory": "Fotokategorie", @@ -827,7 +828,12 @@ "coverPhotoSet": "Titelbild erfolgreich festgelegt", "coverPhotoRemoved": "Titelbild entfernt", "failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden", - "categoryHeroHint": "Wenn kein Titelbild für eine Kategorie festgelegt ist, wird das Standard-Hero-Foto verwendet." + "categoryHeroHint": "Wenn kein Titelbild für eine Kategorie festgelegt ist, wird das Standard-Hero-Foto verwendet.", + "downloadsEnabled": "Downloads für diese Kategorie aktiviert", + "downloadsDisabled": "Downloads für diese Kategorie deaktiviert", + "enableDownloadsTitle": "Klicken zum Aktivieren der Downloads für diese Kategorie", + "disableDownloadsTitle": "Klicken zum Deaktivieren der Downloads für diese Kategorie", + "failedToToggleDownloads": "Aktualisierung der Download-Berechtigung fehlgeschlagen" }, "events": { "totalPhotos": "Gesamtfotos", @@ -1756,6 +1762,10 @@ "title": "Projekte", "description": "Nur-Admin-Gruppierungsebene über Events. Bündle mehrere Events unter einem Projekt und öffne ein 360°-Projektübersichts-Cockpit — Meilenstein-Zeitleiste plus ein datierter Verlauf aller E-Mails (mit der tatsächlich gesendeten Vorschau + Erneut-senden/Abbrechen/Wiederholen-Aktionen), Angebote, Verträge, Rechnungen, Galerien und erfassten Stunden. Fügt beim Erfassen von Stunden eine „Auf Projekt buchen“-Option hinzu. Kunden sehen Projekte nie.", "sidebar": "Übersicht" + }, + "whatsapp": { + "title": "WhatsApp", + "description": "Liefert die Gallerie-Bereit-Benachrichtigung zusätzlich zur E-Mail über die WhatsApp Business API. Voraussetzung: Meta-Business-Konto, genehmigte Nachrichtenvorlage und eine Kunden-Telefonnummer am Event. Zugangsdaten unter Einstellungen → WhatsApp konfigurieren." } }, "customerSurface": { @@ -1802,6 +1812,26 @@ "hourlyRatePlaceholder": "z. B. 120.00", "hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen." } + }, + "whatsapp": { + "title": "WhatsApp", + "subtitle": "Meta-Business-Zugangsdaten konfigurieren, um die Gallerie-Bereit-Benachrichtigung zusätzlich zur E-Mail per WhatsApp zu senden.", + "phoneNumberId": "Phone Number ID", + "phoneNumberIdHint": "Aus Meta Business → WhatsApp → API-Einrichtung. Die numerische ID, die Meta der hinterlegten Telefonnummer zuweist.", + "wabaId": "WABA-ID", + "wabaIdHint": "WhatsApp-Business-Konto-ID. Nur als Referenz (der API-Aufruf nutzt die Phone Number ID); nützlich für Audits.", + "accessToken": "Zugriffs-Token", + "accessTokenPlaceholder": "EAAB… (System-User-Token empfohlen)", + "accessTokenHint": "Beim Abruf maskiert als \"********\" gespeichert. Maskierten Wert beibehalten, um das bestehende Token zu behalten; neuen Wert eingeben, um zu ersetzen.", + "templateName": "Vorlagenname", + "templateNameHint": "Name der von Meta genehmigten Nachrichtenvorlage. Die Standardvorlage `gallery_ready` erwartet 5 Body-Parameter: Kundenname, Event-Name, Galerie-Link, Passwortzeile, Ablaufdatum. Die Vorlage vor der Aktivierung im Meta Business Manager genehmigen lassen.", + "enabled": "WhatsApp-Benachrichtigungen senden", + "savedToast": "WhatsApp-Einstellungen gespeichert.", + "testHeading": "Testnachricht senden", + "testHelp": "Sendet eine statische Vorlagennachricht an die angegebene Telefonnummer, um Meta-Zugangsdaten und Vorlagenfreigabe zu prüfen. Mit Ländervorwahl (z. B. +49…).", + "testSend": "Test senden", + "testSending": "Senden…", + "testSentToast": "Testnachricht gesendet (ID: {{id}})." } }, "branding": { @@ -2279,7 +2309,8 @@ "feedbackDeleted": "Feedback gelöscht", "feedbackModerated": "Feedback moderiert", "feedbackSettingsUpdated": "Feedback-Einstellungen aktualisiert für {{eventName}}", - "wordFilterAdded": "Wortfilter hinzugefügt: {{word}}" + "wordFilterAdded": "Wortfilter hinzugefügt: {{word}}", + "whatsappConfigUpdated": "WhatsApp-Konfiguration aktualisiert" }, "notificationToasts": { "markedAllRead": "Alle Benachrichtigungen als gelesen markiert", @@ -2532,7 +2563,8 @@ "admin_user_deleted": "Admin-Konto gelöscht: {{username}}", "email_queue_flushed": "E-Mail-Warteschlange geleert", "email_template_created": "E-Mail-Vorlage erstellt: {{template_key}}", - "event_duplicated": "Event dupliziert aus {{source_event_name}}" + "event_duplicated": "Event dupliziert aus {{source_event_name}}", + "whatsapp_config_updated": "WhatsApp-Konfiguration aktualisiert" } }, "acceptInvitation": { @@ -3061,7 +3093,10 @@ "onPhoto": "Auf Foto", "showAll_one": "Alle {{count}} ausstehenden Kommentare anzeigen", "showAll_other": "Alle {{count}} ausstehenden Kommentare anzeigen", - "viewAllFeedback": "Gesamtes Feedback & Einstellungen anzeigen" + "viewAllFeedback": "Gesamtes Feedback & Einstellungen anzeigen", + "exportShapeLabel": "Form", + "exportShapeLong": "Pro Aktion (lang)", + "exportShapePivot": "Pro Gast (pivot)" }, "filter": { "feedbackFilters": "Feedback-Filter", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2de7a660..06092183 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -151,7 +151,8 @@ "preview": "Preview", "duplicate": "Duplicate", "showAll": "Show all", - "confirm": "Confirm" + "confirm": "Confirm", + "show": "Show" }, "upload": { "photoCategory": "Photo Category", @@ -385,7 +386,12 @@ "coverPhotoSet": "Cover photo set successfully", "coverPhotoRemoved": "Cover photo removed", "failedToSetCoverPhoto": "Failed to set cover photo", - "categoryHeroHint": "If no cover photo is set for a category, the default hero photo will be used." + "categoryHeroHint": "If no cover photo is set for a category, the default hero photo will be used.", + "downloadsEnabled": "Downloads enabled for this category", + "downloadsDisabled": "Downloads disabled for this category", + "enableDownloadsTitle": "Click to enable downloads for this category", + "disableDownloadsTitle": "Click to disable downloads for this category", + "failedToToggleDownloads": "Failed to update download permission" }, "events": { "title": "Events", @@ -1314,6 +1320,10 @@ "title": "Projects", "description": "Admin-only grouping layer above events. Bundle several events under one project and open a 360° Project Overview cockpit — milestone timeline plus a dated feed of every email (with the actual sent preview + resend/cancel/retry actions), quote, contract, invoice, gallery and logged hour. Adds a \"book to project\" control when logging hours. Customers never see projects.", "sidebar": "Overview" + }, + "whatsapp": { + "title": "WhatsApp", + "description": "Deliver the gallery-ready notification via WhatsApp Business API in addition to email. Requires a Meta Business Account, an approved message template, and a customer phone number on the event. Configure credentials under Settings → WhatsApp." } }, "customerSurface": { @@ -1360,6 +1370,26 @@ "hourlyRatePlaceholder": "e.g. 120.00", "hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate." } + }, + "whatsapp": { + "title": "WhatsApp", + "subtitle": "Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.", + "phoneNumberId": "Phone Number ID", + "phoneNumberIdHint": "From Meta Business → WhatsApp → API Setup. The numeric ID Meta assigns to the phone you registered.", + "wabaId": "WABA ID", + "wabaIdHint": "WhatsApp Business Account ID. Reference only (the API call uses the Phone Number ID); helpful for auditing.", + "accessToken": "Access token", + "accessTokenPlaceholder": "EAAB… (system-user token recommended)", + "accessTokenHint": "Stored masked as \"********\" on GET. Leave the masked value to keep the existing token; type a new one to replace it.", + "templateName": "Template name", + "templateNameHint": "Name of the Meta-approved message template. The default `gallery_ready` expects 5 body parameters: customer name, event name, gallery link, password line, expiry date. Approve the template in Meta Business Manager before enabling.", + "enabled": "Send WhatsApp notifications", + "savedToast": "WhatsApp settings saved.", + "testHeading": "Send a test message", + "testHelp": "Sends a static template message to the phone number below to verify Meta credentials + template approval. Includes country code (e.g. +49…).", + "testSend": "Send test", + "testSending": "Sending…", + "testSentToast": "Test message sent (id: {{id}})." } }, "analytics": { @@ -1866,7 +1896,8 @@ "feedbackDeleted": "Feedback deleted", "feedbackModerated": "Feedback moderated", "feedbackSettingsUpdated": "Feedback settings updated for {{eventName}}", - "wordFilterAdded": "Word filter added: {{word}}" + "wordFilterAdded": "Word filter added: {{word}}", + "whatsappConfigUpdated": "WhatsApp configuration updated" }, "notificationToasts": { "markedAllRead": "All notifications marked as read", @@ -2121,7 +2152,8 @@ "admin_user_deleted": "Admin user deleted: {{username}}", "email_queue_flushed": "Email queue flushed", "email_template_created": "Email template created: {{template_key}}", - "event_duplicated": "Event duplicated from {{source_event_name}}" + "event_duplicated": "Event duplicated from {{source_event_name}}", + "whatsapp_config_updated": "WhatsApp configuration updated" } }, "acceptInvitation": { @@ -3082,7 +3114,10 @@ "onPhoto": "On photo", "showAll_one": "Show all {{count}} pending comments", "showAll_other": "Show all {{count}} pending comments", - "viewAllFeedback": "View all feedback & settings" + "viewAllFeedback": "View all feedback & settings", + "exportShapeLabel": "Shape", + "exportShapeLong": "Per-action (long)", + "exportShapePivot": "Per-guest (pivot)" }, "filter": { "feedbackFilters": "Feedback Filters", diff --git a/frontend/src/pages/admin/EventFeedbackPage.tsx b/frontend/src/pages/admin/EventFeedbackPage.tsx index 147d6806..4cf14620 100644 --- a/frontend/src/pages/admin/EventFeedbackPage.tsx +++ b/frontend/src/pages/admin/EventFeedbackPage.tsx @@ -41,6 +41,11 @@ export const EventFeedbackPage: React.FC = () => { page: 1, limit: 20 }); + // Export shape selector (#640 #6). 'long' = one row per individual feedback + // action (backward-compat, what the existing export has always been). + // 'pivot' = one row per (photo, guest) — handier for spreadsheet pivot tables + // and per-guest engagement scans, hidden rows excluded. + const [exportShape, setExportShape] = useState<'long' | 'pivot'>('long'); // Fetch event details const { data: event, isLoading: eventLoading } = useQuery({ @@ -101,23 +106,26 @@ export const EventFeedbackPage: React.FC = () => { } }); - // Export feedback + // Export feedback. Filename carries the shape so multiple exports of the + // same event don't overwrite each other in the admin's Downloads folder. const handleExport = async (format: 'json' | 'csv') => { try { - const data = await feedbackService.exportEventFeedback(id!, format); + const data = await feedbackService.exportEventFeedback(id!, format, exportShape); + const eventSlug = event?.slug || id; + const filename = `feedback-${exportShape}-${eventSlug}.${format}`; if (format === 'csv') { const blob = new Blob([data], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `feedback-${event?.slug || id}.csv`; + a.download = filename; a.click(); } else { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `feedback-${event?.slug || id}.json`; + a.download = filename; a.click(); } toast.success(t('feedback.exported', 'Feedback exported')); @@ -160,7 +168,23 @@ export const EventFeedbackPage: React.FC = () => {

-
+
+ {/* Shape selector (#640 #6). Long is the existing per-action shape; + pivot is per-(photo, guest) for spreadsheet pivot tables. */} +
+ + +