Merge pull request #641 from the-luap/fix/backup-restore-large-archives
fix+feat: backups + per-category downloads + ConfirmDialog + WhatsApp + feedback-pivot (#640 A+B+C+D+E)
This commit is contained in:
@@ -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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
Generated
+25
-12
@@ -1,17 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.60.6-beta.0",
|
"version": "3.65.1-beta.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.60.6-beta.0",
|
"version": "3.65.1-beta.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
"adm-zip": "^0.5.16",
|
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "1.15.2",
|
"axios": "1.15.2",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
@@ -39,6 +38,7 @@
|
|||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
|
"node-stream-zip": "^1.15.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
@@ -314,6 +314,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz",
|
||||||
"integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==",
|
"integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-crypto/sha1-browser": "5.2.0",
|
"@aws-crypto/sha1-browser": "5.2.0",
|
||||||
"@aws-crypto/sha256-browser": "5.2.0",
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
@@ -1042,6 +1043,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -3804,6 +3806,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3821,15 +3824,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@@ -4373,6 +4367,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"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.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -5765,6 +5761,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@@ -6791,6 +6788,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
@@ -9058,6 +9056,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.10",
|
"version": "8.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||||
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"fontkit": "^2.0.4",
|
"fontkit": "^2.0.4",
|
||||||
@@ -10621,6 +10633,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
|
||||||
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
|
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"parseley": "~0.13.1"
|
"parseley": "~0.13.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
"adm-zip": "^0.5.16",
|
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "1.15.2",
|
"axios": "1.15.2",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
@@ -45,6 +44,7 @@
|
|||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
|
"node-stream-zip": "^1.15.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
|
|||||||
@@ -639,6 +639,7 @@ app.use('/api/admin', adminRoutes);
|
|||||||
app.use('/api/admin/auth', adminAuthRoutes);
|
app.use('/api/admin/auth', adminAuthRoutes);
|
||||||
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
||||||
app.use('/api/admin/feature-flags', require('./src/routes/adminFeatureFlags'));
|
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/backup', require('./src/routes/adminBackup'));
|
||||||
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
||||||
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||||
@@ -842,6 +843,15 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
startEmailQueueProcessor();
|
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
|
// Start incoming-mail (IMAP) poller — no-ops each minute unless the
|
||||||
// `incomingMail` flag is on and a mailbox is configured (migration 128).
|
// `incomingMail` flag is on and a mailbox is configured (migration 128).
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const { slugify } = require('../utils/slug');
|
|||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const AdmZip = require('adm-zip');
|
const StreamZip = require('node-stream-zip');
|
||||||
const { requireEventOwnership } = require('../middleware/ownership');
|
const { requireEventOwnership } = require('../middleware/ownership');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -167,7 +167,11 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
|||||||
|
|
||||||
// Extract the archive
|
// Extract the archive
|
||||||
try {
|
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 eventsDir = path.join(storagePath, 'events/active');
|
||||||
const eventDir = path.join(eventsDir, archive.slug);
|
const eventDir = path.join(eventsDir, archive.slug);
|
||||||
|
|
||||||
@@ -176,11 +180,37 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
|||||||
|
|
||||||
// Log ZIP contents for debugging
|
// Log ZIP contents for debugging
|
||||||
console.log(`Extracting archive to: ${eventDir}`);
|
console.log(`Extracting archive to: ${eventDir}`);
|
||||||
const entries = zip.getEntries();
|
const entries = Object.values(await zip.entries());
|
||||||
console.log(`Archive contains ${entries.length} entries`);
|
console.log(`Archive contains ${entries.length} entries`);
|
||||||
|
|
||||||
// Extract files to the event directory
|
// Stream-extract everything to disk
|
||||||
zip.extractAllTo(eventDir, true);
|
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
|
// Get list of extracted files to update database
|
||||||
const extractedPhotos = [];
|
const extractedPhotos = [];
|
||||||
@@ -189,10 +219,10 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
|||||||
const categoriesMap = new Map();
|
const categoriesMap = new Map();
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory && entry.entryName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
if (!entry.isDirectory && entry.name.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
||||||
const filename = path.basename(entry.entryName);
|
const filename = path.basename(entry.name);
|
||||||
const dirPath = path.dirname(entry.entryName);
|
const dirPath = path.dirname(entry.name);
|
||||||
const actualFilePath = path.join(eventDir, entry.entryName);
|
const actualFilePath = path.join(eventDir, entry.name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if file was extracted successfully
|
// Check if file was extracted successfully
|
||||||
@@ -239,10 +269,14 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
|||||||
if (!existingPhoto) {
|
if (!existingPhoto) {
|
||||||
// Store relative path from storage root
|
// Store relative path from storage root
|
||||||
const relativePath = path.relative(storagePath, actualFilePath);
|
const relativePath = path.relative(storagePath, actualFilePath);
|
||||||
|
const manifestEntry = manifestByFilename.get(filename);
|
||||||
extractedPhotos.push({
|
extractedPhotos.push({
|
||||||
event_id: archive.id,
|
event_id: archive.id,
|
||||||
filename: filename,
|
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,
|
path: relativePath,
|
||||||
thumbnail_path: null, // Will be regenerated by thumbnail service
|
thumbnail_path: null, // Will be regenerated by thumbnail service
|
||||||
type: path.extname(filename).substring(1).toLowerCase(),
|
type: path.extname(filename).substring(1).toLowerCase(),
|
||||||
@@ -253,7 +287,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
|||||||
}
|
}
|
||||||
} catch (statError) {
|
} catch (statError) {
|
||||||
console.error(`Failed to stat file: ${actualFilePath}`);
|
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);
|
console.error('Error:', statError.message);
|
||||||
// Skip this file if we can't stat it
|
// Skip this file if we can't stat it
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
|||||||
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
|
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
|
||||||
if (value === null || value === undefined) return true;
|
if (value === null || value === undefined) return true;
|
||||||
return Number.isInteger(Number(value));
|
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) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -144,6 +145,11 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
|||||||
updateData.hero_photo_id = hero_photo_id || null;
|
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')
|
await db('photo_categories')
|
||||||
.where('id', id)
|
.where('id', id)
|
||||||
.update(updateData);
|
.update(updateData);
|
||||||
|
|||||||
@@ -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
|
// Fire event.published when the event is created NOT as a draft. The
|
||||||
// separate /publish endpoint fires it for the draft → live transition;
|
// separate /publish endpoint fires it for the draft → live transition;
|
||||||
// this covers the "create-and-publish in one shot" path.
|
// 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',
|
await logActivity('event_published',
|
||||||
{ event_name: event.event_name },
|
{ event_name: event.event_name },
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ const KNOWN_FLAGS = [
|
|||||||
// the Project Overview cockpit ("book to project" hours control, 360°
|
// the Project Overview cockpit ("book to project" hours control, 360°
|
||||||
// rollup feed). Lights up the Clients section. Customers never see it.
|
// rollup feed). Lights up the Clients section. Customers never see it.
|
||||||
'projects',
|
'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
|
// 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,
|
incomingInvoices: false,
|
||||||
expenses: false,
|
expenses: false,
|
||||||
projects: false,
|
projects: false,
|
||||||
|
whatsapp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function readAllFlags() {
|
async function readAllFlags() {
|
||||||
|
|||||||
@@ -315,15 +315,21 @@ router.get('/events/:eventId/feedback/export',
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
const { format = 'json' } = req.query;
|
const { format = 'json', shape = 'long' } = req.query;
|
||||||
|
|
||||||
const feedback = await feedbackService.exportEventFeedback(eventId);
|
// 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') {
|
if (format === 'csv') {
|
||||||
// Convert to CSV
|
|
||||||
const csv = convertToCSV(feedback);
|
const csv = convertToCSV(feedback);
|
||||||
|
const fileSuffix = isPivot ? 'pivot' : 'long';
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
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);
|
res.send(csv);
|
||||||
} else {
|
} else {
|
||||||
res.json(feedback);
|
res.json(feedback);
|
||||||
@@ -428,7 +434,11 @@ 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) {
|
function convertToCSV(data) {
|
||||||
if (!data || data.length === 0) return '';
|
if (!data || data.length === 0) return '';
|
||||||
|
|
||||||
@@ -438,11 +448,13 @@ function convertToCSV(data) {
|
|||||||
const csvRows = data.map(row => {
|
const csvRows = data.map(row => {
|
||||||
return headers.map(header => {
|
return headers.map(header => {
|
||||||
const value = row[header];
|
const value = row[header];
|
||||||
// Escape quotes and wrap in quotes if contains comma
|
if (value === null || value === undefined) return '';
|
||||||
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
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.replace(/"/g, '""')}"`;
|
||||||
}
|
}
|
||||||
return value || '';
|
return value;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -396,7 +396,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
|||||||
if (usedCategoryIds.length > 0) {
|
if (usedCategoryIds.length > 0) {
|
||||||
const categoryDetails = await db('photo_categories')
|
const categoryDetails = await db('photo_categories')
|
||||||
.whereIn('id', usedCategoryIds)
|
.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');
|
.orderBy('name', 'asc');
|
||||||
|
|
||||||
categories = categoryDetails.map(cat => ({
|
categories = categoryDetails.map(cat => ({
|
||||||
@@ -404,7 +404,11 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
|||||||
name: cat.name,
|
name: cat.name,
|
||||||
slug: cat.slug,
|
slug: cat.slug,
|
||||||
is_global: cat.is_global,
|
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,
|
type: photo.type,
|
||||||
category_id: photo.category_id || null,
|
category_id: photo.category_id || null,
|
||||||
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : 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,
|
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
|
||||||
size: photo.size_bytes,
|
size: photo.size_bytes,
|
||||||
uploaded_at: photo.uploaded_at,
|
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' });
|
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
|
// Update download count
|
||||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
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 })
|
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')
|
const photos = await db('photos')
|
||||||
|
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||||
.where('photos.event_id', req.event.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.*')
|
.select('photos.*')
|
||||||
.orderBy('photos.type', 'asc')
|
.orderBy('photos.type', 'asc')
|
||||||
.orderBy('photos.uploaded_at', 'desc');
|
.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' });
|
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')
|
const photos = await db('photos')
|
||||||
|
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||||
.where('photos.event_id', req.event.id)
|
.where('photos.event_id', req.event.id)
|
||||||
.whereIn('photos.id', photoIds)
|
.whereIn('photos.id', photoIds)
|
||||||
|
.where(function () {
|
||||||
|
this.whereNull('photos.category_id')
|
||||||
|
.orWhere('photo_categories.allow_downloads', true)
|
||||||
|
.orWhereNull('photo_categories.allow_downloads');
|
||||||
|
})
|
||||||
.select('photos.*')
|
.select('photos.*')
|
||||||
.orderBy('photos.uploaded_at', 'desc');
|
.orderBy('photos.uploaded_at', 'desc');
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,36 @@ async function archiveEvent(event) {
|
|||||||
const tmpArchive = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-${archiveName}`);
|
const tmpArchive = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-${archiveName}`);
|
||||||
|
|
||||||
try {
|
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.
|
// Collect feedback data first so it can be included as in-memory entries.
|
||||||
const feedbackEntries = [];
|
const feedbackEntries = [];
|
||||||
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
|
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||||
@@ -116,6 +146,9 @@ async function archiveEvent(event) {
|
|||||||
for (const f of feedbackEntries) {
|
for (const f of feedbackEntries) {
|
||||||
archive.append(f.buffer, { name: f.name });
|
archive.append(f.buffer, { name: f.name });
|
||||||
}
|
}
|
||||||
|
if (photosManifestEntry) {
|
||||||
|
archive.append(photosManifestEntry.buffer, { name: photosManifestEntry.name });
|
||||||
|
}
|
||||||
archive.finalize();
|
archive.finalize();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
async exportEventFeedback(eventId) {
|
||||||
try {
|
try {
|
||||||
@@ -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
|
* Get filtered photos based on feedback criteria
|
||||||
* @param {number} eventId - Event ID
|
* @param {number} eventId - Event ID
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -73,6 +73,7 @@ import { RequireFeature } from './components/admin/RequireFeature';
|
|||||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
|
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
|
||||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||||
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
||||||
|
import { ConfirmDialogProvider } from './components/common';
|
||||||
import { usePublicSettings } from './hooks/usePublicSettings';
|
import { usePublicSettings } from './hooks/usePublicSettings';
|
||||||
|
|
||||||
// Create a client
|
// Create a client
|
||||||
@@ -149,6 +150,7 @@ function App() {
|
|||||||
<MaintenanceProvider>
|
<MaintenanceProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<GlobalThemeProvider>
|
<GlobalThemeProvider>
|
||||||
|
<ConfirmDialogProvider>
|
||||||
<DynamicFavicon />
|
<DynamicFavicon />
|
||||||
<RobotsMetaTags />
|
<RobotsMetaTags />
|
||||||
<Router>
|
<Router>
|
||||||
@@ -406,6 +408,7 @@ function App() {
|
|||||||
pauseOnHover
|
pauseOnHover
|
||||||
theme={toastTheme}
|
theme={toastTheme}
|
||||||
/>
|
/>
|
||||||
|
</ConfirmDialogProvider>
|
||||||
</GlobalThemeProvider>
|
</GlobalThemeProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</MaintenanceProvider>
|
</MaintenanceProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { toast } from 'react-toastify';
|
||||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||||
import { photosService } from '../../services/photos.service';
|
import { photosService } from '../../services/photos.service';
|
||||||
@@ -79,6 +79,25 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ 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 = () => {
|
const handleCreate = () => {
|
||||||
if (newCategoryName.trim()) {
|
if (newCategoryName.trim()) {
|
||||||
createMutation.mutate(newCategoryName.trim());
|
createMutation.mutate(newCategoryName.trim());
|
||||||
@@ -202,18 +221,49 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
onClick={() => handleDelete(category)}
|
{/* Per-category downloads toggle (#640). Green DownloadCloud
|
||||||
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
icon when on, struck-through outline when off. The
|
||||||
title={t('categories.deleteCategoryTitle')}
|
event-level `allow_downloads` AND's with this — if the
|
||||||
disabled={deleteMutation.isPending}
|
whole event has downloads off, this toggle is cosmetic. */}
|
||||||
>
|
<button
|
||||||
{deleteMutation.isPending ? (
|
onClick={() => downloadToggleMutation.mutate({
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
category,
|
||||||
) : (
|
allow: category.allow_downloads === false,
|
||||||
<X className="w-3 h-3" />
|
})}
|
||||||
)}
|
className={`p-1 transition-colors ${
|
||||||
</button>
|
category.allow_downloads === false
|
||||||
|
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
|
||||||
|
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
category.allow_downloads === false
|
||||||
|
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
|
||||||
|
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
|
||||||
|
}
|
||||||
|
disabled={downloadToggleMutation.isPending}
|
||||||
|
>
|
||||||
|
{downloadToggleMutation.isPending ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : category.allow_downloads === false ? (
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<DownloadCloud className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(category)}
|
||||||
|
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||||
|
title={t('categories.deleteCategoryTitle')}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 <ConfirmDialogProvider />; 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<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useConfirm = (): ((options: ConfirmOptions) => Promise<boolean>) => {
|
||||||
|
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<ConfirmOptions | null>(null);
|
||||||
|
const resolverRef = useRef<Resolver | null>(null);
|
||||||
|
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
|
||||||
|
return new Promise<boolean>((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 (
|
||||||
|
<ConfirmContext.Provider value={{ confirm }}>
|
||||||
|
{children}
|
||||||
|
{options && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4"
|
||||||
|
onClick={() => settle(false)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className="max-w-md w-full"
|
||||||
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
{Icon && <Icon className={`w-6 h-6 flex-shrink-0 mt-0.5 ${iconClass}`} />}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{options.title && (
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
|
||||||
|
{options.title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300 whitespace-pre-line break-words">
|
||||||
|
{options.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => settle(false)}
|
||||||
|
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||||
|
aria-label={t('common.close', 'Close')}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
ref={cancelButtonRef}
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => settle(false)}
|
||||||
|
>
|
||||||
|
{options.cancelLabel ?? t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirmButtonVariant}
|
||||||
|
onClick={() => settle(true)}
|
||||||
|
className={confirmButtonClass}
|
||||||
|
>
|
||||||
|
{options.confirmLabel ?? t('common.confirm', 'Confirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConfirmContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,3 +29,4 @@ export { ProtectionWarning } from './ProtectionWarning';
|
|||||||
export { ReCaptcha } from './ReCaptcha';
|
export { ReCaptcha } from './ReCaptcha';
|
||||||
export { PasswordGenerator } from './PasswordGenerator';
|
export { PasswordGenerator } from './PasswordGenerator';
|
||||||
export { MarkdownContent } from './MarkdownContent';
|
export { MarkdownContent } from './MarkdownContent';
|
||||||
|
export { ConfirmDialogProvider, useConfirm, type ConfirmOptions, type ConfirmVariant } from './ConfirmDialog';
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
// unsupported browsers fall through to a regular <a download>.
|
// unsupported browsers fall through to a regular <a download>.
|
||||||
const downloadPhotoMutation = useSavePhotoToDevice();
|
const downloadPhotoMutation = useSavePhotoToDevice();
|
||||||
const currentPhoto = photos[currentIndex];
|
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
|
// DevTools protection - enabled by individual setting OR legacy protection level
|
||||||
const devToolsEnabled = enableDevtoolsProtection || (useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum'));
|
const devToolsEnabled = enableDevtoolsProtection || (useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum'));
|
||||||
@@ -171,7 +176,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
break;
|
break;
|
||||||
case 'd':
|
case 'd':
|
||||||
case 'D':
|
case 'D':
|
||||||
if (allowDownloads) {
|
if (photoAllowsDownload) {
|
||||||
handleDownload();
|
handleDownload();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -353,7 +358,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
if (!allowDownloads) return;
|
if (!photoAllowsDownload) return;
|
||||||
downloadPhotoMutation.mutate({
|
downloadPhotoMutation.mutate({
|
||||||
slug,
|
slug,
|
||||||
photoId: currentPhoto.id,
|
photoId: currentPhoto.id,
|
||||||
@@ -680,7 +685,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
|
|
||||||
<div className="w-px h-6 bg-white/20 mx-2" />
|
<div className="w-px h-6 bg-white/20 mx-2" />
|
||||||
|
|
||||||
{allowDownloads && (
|
{photoAllowsDownload && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|||||||
// the Project Overview cockpit. Off by default — admin opts in under
|
// the Project Overview cockpit. Off by default — admin opts in under
|
||||||
// Settings → Features once they want the CRM → Overview area.
|
// Settings → Features once they want the CRM → Overview area.
|
||||||
projects: false,
|
projects: false,
|
||||||
|
// WhatsApp Business API delivery channel (migration 136, #640D).
|
||||||
|
whatsapp: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
|
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ export { ThumbnailsTab } from './tabs/ThumbnailsTab';
|
|||||||
export { ApiTokensTab } from './tabs/ApiTokensTab';
|
export { ApiTokensTab } from './tabs/ApiTokensTab';
|
||||||
export { WebhooksTab } from './tabs/WebhooksTab';
|
export { WebhooksTab } from './tabs/WebhooksTab';
|
||||||
export { AccountingTab } from './tabs/AccountingTab';
|
export { AccountingTab } from './tabs/AccountingTab';
|
||||||
|
export { WhatsAppTab } from './tabs/WhatsAppTab';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Images,
|
Images,
|
||||||
BellRing,
|
BellRing,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Smartphone,
|
||||||
Mailbox,
|
Mailbox,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
@@ -179,6 +180,21 @@ export const FeaturesTab: React.FC = () => {
|
|||||||
onToggle={(next) => setFlag('incomingMail', next)}
|
onToggle={(next) => setFlag('incomingMail', next)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FeatureCard
|
||||||
|
icon={Smartphone}
|
||||||
|
title={t('settings.features.whatsapp.title', 'WhatsApp')}
|
||||||
|
description={t(
|
||||||
|
'settings.features.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.',
|
||||||
|
)}
|
||||||
|
status="new"
|
||||||
|
statusLabel={statusLabel('new')}
|
||||||
|
sidebarHidden
|
||||||
|
sidebarHiddenLabel={sidebarHiddenLabel}
|
||||||
|
enabled={staged.whatsapp}
|
||||||
|
onToggle={(next) => setFlag('whatsapp', next)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
title={t('settings.features.messaging.title', 'Messaging')}
|
title={t('settings.features.messaging.title', 'Messaging')}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Save, Send, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { Button, Card, CardContent, Input, Loading } from '../../../components/common';
|
||||||
|
import { whatsappService } from '../../../services/whatsapp.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp Business API configuration tab (#640D).
|
||||||
|
*
|
||||||
|
* Stores the Meta phone_number_id + waba_id + access_token + approved
|
||||||
|
* template_name. Access token is masked on GET (server returns '********');
|
||||||
|
* the PUT silently preserves the stored token when the user doesn't supply
|
||||||
|
* a fresh one — they can edit other fields without re-entering it. Enabling
|
||||||
|
* with no token (and none stored) fails at the route validator.
|
||||||
|
*
|
||||||
|
* The Test action fires a static template message at a phone the admin
|
||||||
|
* provides — useful to verify the credentials + template approval state
|
||||||
|
* without waiting for a real event-published trigger.
|
||||||
|
*/
|
||||||
|
export const WhatsAppTab: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['whatsapp-config'],
|
||||||
|
queryFn: () => whatsappService.getConfig(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [phoneNumberId, setPhoneNumberId] = useState('');
|
||||||
|
const [wabaId, setWabaId] = useState('');
|
||||||
|
const [accessToken, setAccessToken] = useState('');
|
||||||
|
const [templateName, setTemplateName] = useState('gallery_ready');
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
const [testPhone, setTestPhone] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setPhoneNumberId(data.phone_number_id || '');
|
||||||
|
setWabaId(data.waba_id || '');
|
||||||
|
// Server returns '********' when a token is stored, '' when none is.
|
||||||
|
// Leave it visible-as-masked so the admin sees that a token exists.
|
||||||
|
setAccessToken(data.access_token || '');
|
||||||
|
setTemplateName(data.template_name || 'gallery_ready');
|
||||||
|
setEnabled(Boolean(data.enabled));
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => whatsappService.updateConfig({
|
||||||
|
phone_number_id: phoneNumberId,
|
||||||
|
waba_id: wabaId,
|
||||||
|
access_token: accessToken,
|
||||||
|
template_name: templateName,
|
||||||
|
enabled,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('settings.whatsapp.savedToast', 'WhatsApp settings saved.'));
|
||||||
|
qc.invalidateQueries({ queryKey: ['whatsapp-config'] });
|
||||||
|
},
|
||||||
|
onError: (e: any) => {
|
||||||
|
toast.error(e?.response?.data?.error || e.message || 'Save failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendTest = useMutation({
|
||||||
|
mutationFn: () => whatsappService.sendTest(testPhone),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
toast.success(
|
||||||
|
t('settings.whatsapp.testSentToast', 'Test message sent (id: {{id}}).', {
|
||||||
|
id: r.messageId || 'unknown',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (e: any) => {
|
||||||
|
toast.error(e?.response?.data?.error || e.message || 'Test send failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.whatsapp.title', 'WhatsApp')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
|
||||||
|
{t(
|
||||||
|
'settings.whatsapp.subtitle',
|
||||||
|
'Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('settings.whatsapp.phoneNumberId', 'Phone Number ID')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={phoneNumberId}
|
||||||
|
onChange={(e) => setPhoneNumberId(e.target.value)}
|
||||||
|
placeholder="123456789012345"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t(
|
||||||
|
'settings.whatsapp.phoneNumberIdHint',
|
||||||
|
'From Meta Business → WhatsApp → API Setup. The numeric ID Meta assigns to the phone you registered.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('settings.whatsapp.wabaId', 'WABA ID')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={wabaId}
|
||||||
|
onChange={(e) => setWabaId(e.target.value)}
|
||||||
|
placeholder="123456789012345"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t(
|
||||||
|
'settings.whatsapp.wabaIdHint',
|
||||||
|
'WhatsApp Business Account ID. Reference only (the API call uses the Phone Number ID); helpful for auditing.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('settings.whatsapp.accessToken', 'Access token')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type={showToken ? 'text' : 'password'}
|
||||||
|
value={accessToken}
|
||||||
|
onChange={(e) => setAccessToken(e.target.value)}
|
||||||
|
placeholder={t('settings.whatsapp.accessTokenPlaceholder', 'EAAB… (system-user token recommended)') as string}
|
||||||
|
rightIcon={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowToken((v) => !v)}
|
||||||
|
className="p-1"
|
||||||
|
aria-label={showToken ? t('common.hide', 'Hide') : t('common.show', 'Show')}
|
||||||
|
>
|
||||||
|
{showToken ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{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.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('settings.whatsapp.templateName', 'Template name')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={templateName}
|
||||||
|
onChange={(e) => setTemplateName(e.target.value)}
|
||||||
|
placeholder="gallery_ready"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{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.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
className="rounded border-neutral-300"
|
||||||
|
/>
|
||||||
|
{t('settings.whatsapp.enabled', 'Send WhatsApp notifications')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
disabled={save.isPending}
|
||||||
|
leftIcon={<Save className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Test send card — separate so the admin sees it as a distinct action,
|
||||||
|
not a sub-step of saving. */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.whatsapp.testHeading', 'Send a test message')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{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…).',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 items-start">
|
||||||
|
<Input
|
||||||
|
value={testPhone}
|
||||||
|
onChange={(e) => setTestPhone(e.target.value)}
|
||||||
|
placeholder="+49123456789"
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => sendTest.mutate()}
|
||||||
|
disabled={!testPhone.trim() || sendTest.isPending}
|
||||||
|
leftIcon={<Send className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{sendTest.isPending
|
||||||
|
? t('settings.whatsapp.testSending', 'Sending…')
|
||||||
|
: t('settings.whatsapp.testSend', 'Send test')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WhatsAppTab;
|
||||||
@@ -151,7 +151,8 @@
|
|||||||
"preview": "Vorschau",
|
"preview": "Vorschau",
|
||||||
"duplicate": "Duplizieren",
|
"duplicate": "Duplizieren",
|
||||||
"showAll": "Alle anzeigen",
|
"showAll": "Alle anzeigen",
|
||||||
"confirm": "Bestätigen"
|
"confirm": "Bestätigen",
|
||||||
|
"show": "Einblenden"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"photoCategory": "Fotokategorie",
|
"photoCategory": "Fotokategorie",
|
||||||
@@ -827,7 +828,12 @@
|
|||||||
"coverPhotoSet": "Titelbild erfolgreich festgelegt",
|
"coverPhotoSet": "Titelbild erfolgreich festgelegt",
|
||||||
"coverPhotoRemoved": "Titelbild entfernt",
|
"coverPhotoRemoved": "Titelbild entfernt",
|
||||||
"failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden",
|
"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": {
|
"events": {
|
||||||
"totalPhotos": "Gesamtfotos",
|
"totalPhotos": "Gesamtfotos",
|
||||||
@@ -1756,6 +1762,10 @@
|
|||||||
"title": "Projekte",
|
"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.",
|
"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"
|
"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": {
|
"customerSurface": {
|
||||||
@@ -1802,6 +1812,26 @@
|
|||||||
"hourlyRatePlaceholder": "z. B. 120.00",
|
"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."
|
"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": {
|
"branding": {
|
||||||
@@ -2279,7 +2309,8 @@
|
|||||||
"feedbackDeleted": "Feedback gelöscht",
|
"feedbackDeleted": "Feedback gelöscht",
|
||||||
"feedbackModerated": "Feedback moderiert",
|
"feedbackModerated": "Feedback moderiert",
|
||||||
"feedbackSettingsUpdated": "Feedback-Einstellungen aktualisiert für {{eventName}}",
|
"feedbackSettingsUpdated": "Feedback-Einstellungen aktualisiert für {{eventName}}",
|
||||||
"wordFilterAdded": "Wortfilter hinzugefügt: {{word}}"
|
"wordFilterAdded": "Wortfilter hinzugefügt: {{word}}",
|
||||||
|
"whatsappConfigUpdated": "WhatsApp-Konfiguration aktualisiert"
|
||||||
},
|
},
|
||||||
"notificationToasts": {
|
"notificationToasts": {
|
||||||
"markedAllRead": "Alle Benachrichtigungen als gelesen markiert",
|
"markedAllRead": "Alle Benachrichtigungen als gelesen markiert",
|
||||||
@@ -2532,7 +2563,8 @@
|
|||||||
"admin_user_deleted": "Admin-Konto gelöscht: {{username}}",
|
"admin_user_deleted": "Admin-Konto gelöscht: {{username}}",
|
||||||
"email_queue_flushed": "E-Mail-Warteschlange geleert",
|
"email_queue_flushed": "E-Mail-Warteschlange geleert",
|
||||||
"email_template_created": "E-Mail-Vorlage erstellt: {{template_key}}",
|
"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": {
|
"acceptInvitation": {
|
||||||
@@ -3061,7 +3093,10 @@
|
|||||||
"onPhoto": "Auf Foto",
|
"onPhoto": "Auf Foto",
|
||||||
"showAll_one": "Alle {{count}} ausstehenden Kommentare anzeigen",
|
"showAll_one": "Alle {{count}} ausstehenden Kommentare anzeigen",
|
||||||
"showAll_other": "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": {
|
"filter": {
|
||||||
"feedbackFilters": "Feedback-Filter",
|
"feedbackFilters": "Feedback-Filter",
|
||||||
|
|||||||
@@ -151,7 +151,8 @@
|
|||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
"showAll": "Show all",
|
"showAll": "Show all",
|
||||||
"confirm": "Confirm"
|
"confirm": "Confirm",
|
||||||
|
"show": "Show"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"photoCategory": "Photo Category",
|
"photoCategory": "Photo Category",
|
||||||
@@ -385,7 +386,12 @@
|
|||||||
"coverPhotoSet": "Cover photo set successfully",
|
"coverPhotoSet": "Cover photo set successfully",
|
||||||
"coverPhotoRemoved": "Cover photo removed",
|
"coverPhotoRemoved": "Cover photo removed",
|
||||||
"failedToSetCoverPhoto": "Failed to set cover photo",
|
"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": {
|
"events": {
|
||||||
"title": "Events",
|
"title": "Events",
|
||||||
@@ -1314,6 +1320,10 @@
|
|||||||
"title": "Projects",
|
"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.",
|
"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"
|
"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": {
|
"customerSurface": {
|
||||||
@@ -1360,6 +1370,26 @@
|
|||||||
"hourlyRatePlaceholder": "e.g. 120.00",
|
"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."
|
"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": {
|
"analytics": {
|
||||||
@@ -1866,7 +1896,8 @@
|
|||||||
"feedbackDeleted": "Feedback deleted",
|
"feedbackDeleted": "Feedback deleted",
|
||||||
"feedbackModerated": "Feedback moderated",
|
"feedbackModerated": "Feedback moderated",
|
||||||
"feedbackSettingsUpdated": "Feedback settings updated for {{eventName}}",
|
"feedbackSettingsUpdated": "Feedback settings updated for {{eventName}}",
|
||||||
"wordFilterAdded": "Word filter added: {{word}}"
|
"wordFilterAdded": "Word filter added: {{word}}",
|
||||||
|
"whatsappConfigUpdated": "WhatsApp configuration updated"
|
||||||
},
|
},
|
||||||
"notificationToasts": {
|
"notificationToasts": {
|
||||||
"markedAllRead": "All notifications marked as read",
|
"markedAllRead": "All notifications marked as read",
|
||||||
@@ -2121,7 +2152,8 @@
|
|||||||
"admin_user_deleted": "Admin user deleted: {{username}}",
|
"admin_user_deleted": "Admin user deleted: {{username}}",
|
||||||
"email_queue_flushed": "Email queue flushed",
|
"email_queue_flushed": "Email queue flushed",
|
||||||
"email_template_created": "Email template created: {{template_key}}",
|
"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": {
|
"acceptInvitation": {
|
||||||
@@ -3082,7 +3114,10 @@
|
|||||||
"onPhoto": "On photo",
|
"onPhoto": "On photo",
|
||||||
"showAll_one": "Show all {{count}} pending comments",
|
"showAll_one": "Show all {{count}} pending comments",
|
||||||
"showAll_other": "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": {
|
"filter": {
|
||||||
"feedbackFilters": "Feedback Filters",
|
"feedbackFilters": "Feedback Filters",
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
page: 1,
|
page: 1,
|
||||||
limit: 20
|
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
|
// Fetch event details
|
||||||
const { data: event, isLoading: eventLoading } = useQuery({
|
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') => {
|
const handleExport = async (format: 'json' | 'csv') => {
|
||||||
try {
|
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') {
|
if (format === 'csv') {
|
||||||
const blob = new Blob([data], { type: 'text/csv' });
|
const blob = new Blob([data], { type: 'text/csv' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `feedback-${event?.slug || id}.csv`;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
} else {
|
} else {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `feedback-${event?.slug || id}.json`;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
}
|
}
|
||||||
toast.success(t('feedback.exported', 'Feedback exported'));
|
toast.success(t('feedback.exported', 'Feedback exported'));
|
||||||
@@ -160,7 +168,23 @@ export const EventFeedbackPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-end">
|
||||||
|
{/* Shape selector (#640 #6). Long is the existing per-action shape;
|
||||||
|
pivot is per-(photo, guest) for spreadsheet pivot tables. */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-neutral-500 dark:text-neutral-400" htmlFor="feedback-export-shape">
|
||||||
|
{t('feedback.exportShapeLabel', 'Shape')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="feedback-export-shape"
|
||||||
|
value={exportShape}
|
||||||
|
onChange={(e) => setExportShape(e.target.value as 'long' | 'pivot')}
|
||||||
|
className="text-sm px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<option value="long">{t('feedback.exportShapeLong', 'Per-action (long)')}</option>
|
||||||
|
<option value="pivot">{t('feedback.exportShapePivot', 'Per-guest (pivot)')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
ApiTokensTab,
|
ApiTokensTab,
|
||||||
WebhooksTab,
|
WebhooksTab,
|
||||||
AccountingTab,
|
AccountingTab,
|
||||||
|
WhatsAppTab,
|
||||||
} from '../../features/settings';
|
} from '../../features/settings';
|
||||||
import { EmailConfigPage } from './EmailConfigPage';
|
import { EmailConfigPage } from './EmailConfigPage';
|
||||||
import { BrandingPage } from './BrandingPage';
|
import { BrandingPage } from './BrandingPage';
|
||||||
@@ -53,7 +54,7 @@ import { CrmSettingsPage } from './settings/CrmSettingsPage';
|
|||||||
import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage';
|
import { ReminderTemplatesPage } from './settings/ReminderTemplatesPage';
|
||||||
import { BlockLibraryPage } from './contracts/BlockLibraryPage';
|
import { BlockLibraryPage } from './contracts/BlockLibraryPage';
|
||||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||||
import { Briefcase, Receipt, ScrollText, Mail, Landmark } from 'lucide-react';
|
import { Briefcase, Receipt, ScrollText, Mail, Landmark, Smartphone } from 'lucide-react';
|
||||||
|
|
||||||
// Tab keys driving the inner-nav. Must include every key used in
|
// Tab keys driving the inner-nav. Must include every key used in
|
||||||
// `navGroups` below and in the switch at the bottom of the component.
|
// `navGroups` below and in the switch at the bottom of the component.
|
||||||
@@ -83,7 +84,8 @@ type TabType =
|
|||||||
| 'crm'
|
| 'crm'
|
||||||
| 'contracts'
|
| 'contracts'
|
||||||
| 'reminderTemplates'
|
| 'reminderTemplates'
|
||||||
| 'accounting';
|
| 'accounting'
|
||||||
|
| 'whatsapp';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
key: TabType;
|
key: TabType;
|
||||||
@@ -103,7 +105,7 @@ const ALL_TAB_KEYS: TabType[] = [
|
|||||||
'security', 'imageSecurity', 'seo',
|
'security', 'imageSecurity', 'seo',
|
||||||
'apiTokens', 'webhooks',
|
'apiTokens', 'webhooks',
|
||||||
'status', 'analytics', 'backup',
|
'status', 'analytics', 'backup',
|
||||||
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting',
|
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting', 'whatsapp',
|
||||||
];
|
];
|
||||||
|
|
||||||
function isValidTab(value: string | null): value is TabType {
|
function isValidTab(value: string | null): value is TabType {
|
||||||
@@ -113,7 +115,7 @@ function isValidTab(value: string | null): value is TabType {
|
|||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { flags } = useFeatureFlags();
|
const { flags, isLoading: flagsLoading } = useFeatureFlags();
|
||||||
|
|
||||||
// Read ?tab=… on mount; default to Features per the redesign.
|
// Read ?tab=… on mount; default to Features per the redesign.
|
||||||
const initialTab: TabType = isValidTab(searchParams.get('tab'))
|
const initialTab: TabType = isValidTab(searchParams.get('tab'))
|
||||||
@@ -181,6 +183,33 @@ export const SettingsPage: React.FC = () => {
|
|||||||
saveSeoMutation,
|
saveSeoMutation,
|
||||||
} = useSettingsState();
|
} = useSettingsState();
|
||||||
|
|
||||||
|
// If the active tab refers to an item that's now hidden (e.g. admin
|
||||||
|
// landed on ?tab=reminderTemplates after disabling reminderEmails),
|
||||||
|
// snap to the first key that the dependency-rule flags allow. Effect
|
||||||
|
// re-fires when flags toggle live. MUST stay above the isLoading early
|
||||||
|
// return so React's rules-of-hooks count stays consistent across renders
|
||||||
|
// (was previously after the early return — that's a hooks violation that
|
||||||
|
// surfaced as React error #310 once settled long enough for `isLoading`
|
||||||
|
// to transition true→false in the same mount, #640D pre-existing-bug fix).
|
||||||
|
useEffect(() => {
|
||||||
|
// Wait for the server's actual flag values before deciding whether the
|
||||||
|
// current tab is allowed — during initial load `flags` is the defaults
|
||||||
|
// placeholder which would falsely snap-back away from a tab the server
|
||||||
|
// has actually enabled.
|
||||||
|
if (flagsLoading) return;
|
||||||
|
const gatedOff: Record<string, boolean> = {
|
||||||
|
crm: !(flags.quotes || flags.bills || flags.contracts),
|
||||||
|
contracts: !flags.contracts,
|
||||||
|
reminderTemplates: !flags.reminderEmails,
|
||||||
|
accounting: !flags.accounting,
|
||||||
|
whatsapp: !flags.whatsapp,
|
||||||
|
};
|
||||||
|
if (gatedOff[activeTab]) {
|
||||||
|
setActiveTab('features');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [flagsLoading, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, activeTab]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@@ -256,6 +285,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
...(flags.accounting
|
...(flags.accounting
|
||||||
? [{ key: 'accounting' as const, label: t('settings.accounting.title', 'Accounting'), icon: Landmark }]
|
? [{ key: 'accounting' as const, label: t('settings.accounting.title', 'Accounting'), icon: Landmark }]
|
||||||
: []),
|
: []),
|
||||||
|
...(flags.whatsapp
|
||||||
|
? [{ key: 'whatsapp' as const, label: t('settings.whatsapp.title', 'WhatsApp'), icon: Smartphone }]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -270,18 +302,8 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
const allItems = navGroups.flatMap((g) => g.items);
|
const allItems = navGroups.flatMap((g) => g.items);
|
||||||
const activeItem = allItems.find((i) => i.key === activeTab) ?? allItems[0];
|
const activeItem = allItems.find((i) => i.key === activeTab) ?? allItems[0];
|
||||||
|
// (Visibility snap-back is handled in the useEffect above, which sits
|
||||||
// If the active tab refers to an item that's now hidden (e.g. admin
|
// before the isLoading early return to keep hook ordering stable.)
|
||||||
// landed on ?tab=reminderTemplates after disabling reminderEmails),
|
|
||||||
// snap to the first visible item so the content area doesn't render
|
|
||||||
// a hidden tab's UI. Effect re-fires when flags toggle live.
|
|
||||||
useEffect(() => {
|
|
||||||
const visibleKeys = allItems.map((i) => i.key);
|
|
||||||
if (!visibleKeys.includes(activeTab) && visibleKeys.length > 0) {
|
|
||||||
setActiveTab(visibleKeys[0]);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, activeTab]);
|
|
||||||
|
|
||||||
// For tabs that mount existing top-level pages OR bring their own
|
// For tabs that mount existing top-level pages OR bring their own
|
||||||
// header (FeaturesTab has its own icon+title+description block), skip
|
// header (FeaturesTab has its own icon+title+description block), skip
|
||||||
@@ -420,6 +442,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{activeTab === 'contracts' && <BlockLibraryPage />}
|
{activeTab === 'contracts' && <BlockLibraryPage />}
|
||||||
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
|
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
|
||||||
{activeTab === 'accounting' && <AccountingTab />}
|
{activeTab === 'accounting' && <AccountingTab />}
|
||||||
|
{activeTab === 'whatsapp' && <WhatsAppTab />}
|
||||||
|
|
||||||
{activeTab === 'status' && (
|
{activeTab === 'status' && (
|
||||||
<StatusTab
|
<StatusTab
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export interface PhotoCategory {
|
|||||||
is_global: boolean;
|
is_global: boolean;
|
||||||
event_id: number | null;
|
event_id: number | null;
|
||||||
hero_photo_id?: number | null;
|
hero_photo_id?: number | null;
|
||||||
|
// Per-category download permission (#640). Defaults true (server-side) so
|
||||||
|
// categories created before migration 135 keep working.
|
||||||
|
allow_downloads?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +39,16 @@ export const categoriesService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update a category
|
// Update a category. `name` is required by the backend validator; the other
|
||||||
async updateCategory(id: number, name: string): Promise<PhotoCategory> {
|
// fields are optional patches. Per-category `allow_downloads` is the #640
|
||||||
const response = await api.put<PhotoCategory>(`/admin/categories/${id}`, { name });
|
// hook so admins can disable downloads for one category while keeping
|
||||||
|
// everything else downloadable.
|
||||||
|
async updateCategory(
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
patch?: { allow_downloads?: boolean }
|
||||||
|
): Promise<PhotoCategory> {
|
||||||
|
const response = await api.put<PhotoCategory>(`/admin/categories/${id}`, { name, ...patch });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ export type FeatureKey =
|
|||||||
// Projects (migration 120). Admin-only grouping layer above events with the
|
// Projects (migration 120). Admin-only grouping layer above events with the
|
||||||
// 360° Project Overview cockpit + the "book to project" hours control. Off
|
// 360° Project Overview cockpit + the "book to project" hours control. Off
|
||||||
// by default; gates the CRM → Overview area entirely.
|
// by default; gates the CRM → Overview area entirely.
|
||||||
| 'projects';
|
| 'projects'
|
||||||
|
// WhatsApp Business API delivery channel (migration 136, #640D).
|
||||||
|
// Strictly opt-in — requires a Meta Business Account, an approved
|
||||||
|
// message template, and a Meta access token. Independent of email; both
|
||||||
|
// can fire on the same event.
|
||||||
|
| 'whatsapp';
|
||||||
|
|
||||||
export type FeatureFlags = Record<FeatureKey, boolean>;
|
export type FeatureFlags = Record<FeatureKey, boolean>;
|
||||||
|
|
||||||
|
|||||||
@@ -136,9 +136,18 @@ class FeedbackService {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportEventFeedback(eventId: string, format: 'json' | 'csv' = 'json') {
|
// `shape` defaults to 'long' (one row per feedback action) for backward
|
||||||
|
// compatibility with anyone scripting against this endpoint. 'pivot' (per
|
||||||
|
// #640 #6) returns one row per (photo, guest_identifier) with boolean
|
||||||
|
// is_favorited / is_liked plus star_rating + comment. Hidden-by-moderator
|
||||||
|
// rows are excluded from the pivot.
|
||||||
|
async exportEventFeedback(
|
||||||
|
eventId: string,
|
||||||
|
format: 'json' | 'csv' = 'json',
|
||||||
|
shape: 'long' | 'pivot' = 'long',
|
||||||
|
) {
|
||||||
const response = await api.get(`/admin/feedback/events/${eventId}/feedback/export`, {
|
const response = await api.get(`/admin/feedback/events/${eventId}/feedback/export`, {
|
||||||
params: { format },
|
params: { format, shape },
|
||||||
responseType: format === 'csv' ? 'blob' : 'json'
|
responseType: format === 'csv' ? 'blob' : 'json'
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { api } from '../config/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WhatsApp Business API admin config (#640D). The access token is masked on
|
||||||
|
* GET — the server returns `'********'` when a token is stored, the empty
|
||||||
|
* string when none is. The PUT silently preserves the stored token if the
|
||||||
|
* masked sentinel is sent back unchanged.
|
||||||
|
*/
|
||||||
|
export interface WhatsAppConfig {
|
||||||
|
phone_number_id: string;
|
||||||
|
waba_id: string;
|
||||||
|
access_token: string; // masked '********' on GET when a real token is stored
|
||||||
|
template_name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const whatsappService = {
|
||||||
|
async getConfig(): Promise<WhatsAppConfig> {
|
||||||
|
const response = await api.get<WhatsAppConfig>('/admin/whatsapp/config');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateConfig(config: Partial<WhatsAppConfig>): Promise<{ success: true }> {
|
||||||
|
const response = await api.put<{ success: true }>('/admin/whatsapp/config', config);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sends a static test message to the supplied phone number using the
|
||||||
|
// currently-saved config. Returns the Meta message ID on success.
|
||||||
|
async sendTest(phone: string): Promise<{ success: boolean; messageId?: string }> {
|
||||||
|
const response = await api.post<{ success: boolean; messageId?: string }>(
|
||||||
|
'/admin/whatsapp/test',
|
||||||
|
{ phone },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -112,6 +112,11 @@ export interface Photo {
|
|||||||
category_id?: number | string | null;
|
category_id?: number | string | null;
|
||||||
category_name?: string;
|
category_name?: string;
|
||||||
category_slug?: string;
|
category_slug?: string;
|
||||||
|
// Per-category download permission (#640). Defaults true for uncategorised
|
||||||
|
// photos and for categories that pre-date migration 135. The frontend hides
|
||||||
|
// the lightbox download button when this is false (event-level allow_downloads
|
||||||
|
// also has to be true — they AND together).
|
||||||
|
category_allow_downloads?: boolean;
|
||||||
size: number;
|
size: number;
|
||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
captured_at?: string; // EXIF capture date (if available)
|
captured_at?: string; // EXIF capture date (if available)
|
||||||
|
|||||||
Reference in New Issue
Block a user