feat(usage): distinguish real edits and template delivery with v5 consent (#1339)
* feat(usage): distinguish real edits and template delivery with v5 consent * fix(usage): exclude queued test messages and count reorders as edits - queueEmail carries usageEligible: false into email_data and the queue processor passes it on, so the dev tools' send-test-email no longer records email_template_delivery once the worker sends it. - event-types/reorder and categories/reorder-global compare the persisted order before and after and record the v5 edit markers only when it changed, matching the display_order edit already counted on PUT. - normalized() builds arrays with Array.from so a row array from the sqlite binding compares equal under Jest's separate realm. * fix(usage): cover per-gallery category order and workflow test runs - categories/reorder records category_editing when an event's override changes; reorder/:eventId records it when an override was actually removed. - send_email and the collections handoff pass usageEligible: false for a workflow test run (engine.testRun sets __test), so a non-dry test send is not counted as template delivery. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
69754f8a2c
commit
5c1e38d921
@@ -201,8 +201,8 @@ test('public/gallery paths and failed/unauthenticated admin operations never set
|
||||
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
|
||||
});
|
||||
|
||||
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4'])('consent upgrade accepts exactly the explicit %s choice, never extra fields', async (consent_version) => {
|
||||
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v5' }, { consent_version, user: 'PRIVATE' }])
|
||||
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4', 'usage-consent.v5'])('consent upgrade accepts exactly the explicit %s choice, never extra fields', async (consent_version) => {
|
||||
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v6' }, { consent_version, user: 'PRIVATE' }])
|
||||
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`).send(data).expect(400);
|
||||
expect(service.command).not.toHaveBeenCalled();
|
||||
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const knex = require('knex');
|
||||
const { changedFields, settingsChanged } = require('../../src/usage/adoptionEvidence');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({ adminAuth: (req, res, next) => { req.admin = { id: 1 }; next(); } }));
|
||||
jest.mock('../../src/middleware/permissions', () => ({ requirePermission: () => (req, res, next) => next() }));
|
||||
jest.mock('../../src/middleware/requireFeatureFlag', () => ({ requireFeatureFlag: () => (req, res, next) => next() }));
|
||||
jest.mock('../../src/services/productUsageService', () => ({ markUsed: jest.fn().mockResolvedValue() }));
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
htmlToText: body => body, wrapEmailHtml: jest.fn(async body => body), buildSignatureTextFor: jest.fn(async () => ''),
|
||||
}));
|
||||
jest.mock('../../src/services/businessProfileService', () => ({ getEmailSignature: jest.fn(async () => null) }));
|
||||
|
||||
describe('v5 evidence comes from real edits, not the generic successful-route marker', () => {
|
||||
let db, app;
|
||||
const marker = require('../../src/services/productUsageService').markUsed;
|
||||
const recorded = () => marker.mock.calls.flatMap(([keys]) => keys).filter(key => /_editing$/.test(key));
|
||||
beforeAll(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
jest.doMock('../../src/database/db', () => ({ db, logActivity: jest.fn(async () => {}) }));
|
||||
await db.schema.createTable('cms_pages', t => {
|
||||
t.increments('id'); t.string('slug');
|
||||
for (const key of ['title_en', 'title_de', 'content_en', 'content_de', 'logo_url', 'external_url']) t.text(key);
|
||||
t.boolean('use_external_url').defaultTo(false); t.boolean('show_in_footer').defaultTo(true); t.timestamp('updated_at');
|
||||
});
|
||||
await db.schema.createTable('email_templates', t => { t.increments('id'); t.string('template_key'); t.timestamp('updated_at'); });
|
||||
await db.schema.createTable('email_template_translations', t => {
|
||||
t.increments('id'); t.integer('template_id'); t.string('language');
|
||||
for (const key of ['subject', 'body_html', 'body_text']) t.text(key);
|
||||
t.timestamp('updated_at'); t.timestamp('created_at');
|
||||
});
|
||||
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
|
||||
await db.schema.createTable('event_types', t => {
|
||||
t.increments('id'); for (const key of ['name', 'slug_prefix', 'emoji', 'theme_preset', 'theme_config']) t.text(key);
|
||||
t.integer('display_order'); t.boolean('is_active'); t.boolean('is_system'); t.timestamp('updated_at'); t.timestamp('created_at');
|
||||
});
|
||||
await db.schema.createTable('events', t => { t.increments('id'); t.integer('created_by'); });
|
||||
await db.schema.createTable('event_category_order', t => { t.integer('event_id'); t.integer('category_id'); t.integer('position'); });
|
||||
await db.schema.createTable('photo_categories', t => {
|
||||
t.increments('id'); t.text('name'); t.text('slug'); t.integer('hero_photo_id'); t.integer('event_id');
|
||||
t.integer('display_order'); t.boolean('allow_downloads'); t.boolean('is_folder'); t.boolean('is_global');
|
||||
});
|
||||
app = express(); app.use(express.json());
|
||||
app.use(require('../../src/middleware/productUsage').productUsage);
|
||||
app.use('/cms', require('../../src/routes/adminCMS'));
|
||||
app.use('/email', require('../../src/routes/adminEmail'));
|
||||
app.use('/event-types', require('../../src/routes/adminEventTypes'));
|
||||
app.use('/categories', require('../../src/routes/adminCategories'));
|
||||
});
|
||||
beforeEach(async () => {
|
||||
marker.mockClear();
|
||||
for (const table of ['cms_pages', 'email_templates', 'email_template_translations', 'app_settings', 'event_types', 'photo_categories', 'events', 'event_category_order']) await db(table).delete();
|
||||
await db('cms_pages').insert({ slug: 'privacy', title_en: 'Privacy', content_en: 'Seeded content' });
|
||||
await db('email_templates').insert({ id: 1, template_key: 'PRIVATE-template' });
|
||||
await db('email_template_translations').insert({ template_id: 1, language: 'en', subject: 'Seeded subject', body_html: 'Seeded body', body_text: '' });
|
||||
});
|
||||
afterAll(() => db.destroy());
|
||||
test('CMS reads, unchanged saves and external content do not imply internal content editing', async () => {
|
||||
await request(app).get('/cms/pages').expect(200);
|
||||
await request(app).put('/cms/pages/privacy').send({ content_en: 'Seeded content' }).expect(200);
|
||||
await request(app).put('/cms/pages/privacy').send({ use_external_url: true, external_url: 'https://example.test/privacy', content_en: 'Other' }).expect(200);
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).put('/cms/pages/privacy').send({ use_external_url: false, content_en: 'PRIVATE real content' }).expect(200);
|
||||
expect(recorded()).toEqual(['cms_content_editing']);
|
||||
expect(JSON.stringify(marker.mock.calls)).not.toMatch(/PRIVATE|example\.test|privacy/);
|
||||
});
|
||||
test('template preview, empty and unchanged saves are excluded; actual content changes count', async () => {
|
||||
await request(app).post('/email/templates/PRIVATE-template/preview').send({}).expect(200);
|
||||
await request(app).put('/email/templates/PRIVATE-template').send({ translations: {} }).expect(200);
|
||||
const translation = { subject: 'Seeded subject', body_html: 'Seeded body', body_text: '' };
|
||||
await request(app).put('/email/templates/PRIVATE-template').send({ translations: { en: translation } }).expect(200);
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).put('/email/templates/PRIVATE-template').send({ translations: { en: { ...translation, subject: 'PRIVATE custom subject' } } }).expect(200);
|
||||
expect(recorded()).toEqual(['email_template_editing']);
|
||||
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
|
||||
});
|
||||
test('failed saves do not count; new nonempty templates do', async () => {
|
||||
await request(app).put('/email/templates/missing').send({ translations: {} }).expect(404);
|
||||
await request(app).post('/email/templates').send({ template_key: 'empty', translations: {} }).expect(201);
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).post('/email/templates').send({ template_key: 'custom', translations: { de: { subject: 'Privat' } } }).expect(201);
|
||||
expect(recorded()).toEqual(['email_template_editing']);
|
||||
});
|
||||
test('seeded event types and categories count only after a real edit, not identical saves', async () => {
|
||||
await db('event_types').insert({ id: 1, name: 'Wedding', slug_prefix: 'wedding', is_active: true, is_system: true });
|
||||
await db('photo_categories').insert({ id: 1, name: 'All', slug: 'all', is_global: true, is_folder: false });
|
||||
await request(app).get('/event-types').expect(200);
|
||||
await request(app).get('/categories/global').expect(200);
|
||||
await request(app).put('/event-types/1').send({ name: 'Wedding', is_active: true }).expect(200);
|
||||
await request(app).put('/categories/1').send({ name: 'All', is_folder: false }).expect(200);
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).put('/event-types/1').send({ name: 'PRIVATE event type' }).expect(200);
|
||||
await request(app).put('/categories/1').send({ name: 'PRIVATE category' }).expect(200);
|
||||
expect(recorded()).toEqual(['event_type_editing', 'category_editing']);
|
||||
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
|
||||
});
|
||||
test('reordering event types and global categories counts only when the order changes', async () => {
|
||||
await db('event_types').insert([
|
||||
{ id: 1, name: 'A', slug_prefix: 'a', display_order: 1, is_active: true, is_system: true },
|
||||
{ id: 2, name: 'B', slug_prefix: 'b', display_order: 2, is_active: true, is_system: true },
|
||||
]);
|
||||
await db('photo_categories').insert([
|
||||
{ id: 1, name: 'A', slug: 'a', is_global: true, is_folder: false, display_order: 1 },
|
||||
{ id: 2, name: 'B', slug: 'b', is_global: true, is_folder: false, display_order: 2 },
|
||||
]);
|
||||
await db('events').insert({ id: 1 });
|
||||
await request(app).post('/event-types/reorder').send({ orderedIds: [1, 2] }).expect(200);
|
||||
await request(app).post('/categories/reorder-global').send({ orderedIds: [1, 2] }).expect(200);
|
||||
await request(app).delete('/categories/reorder/1').expect(200); // no override to reset
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).post('/event-types/reorder').send({ orderedIds: [2, 1] }).expect(200);
|
||||
await request(app).post('/categories/reorder-global').send({ orderedIds: [2, 1] }).expect(200);
|
||||
await request(app).post('/categories/reorder').send({ event_id: 1, orderedIds: [2, 1] }).expect(200);
|
||||
expect(recorded()).toEqual(['event_type_editing', 'category_editing', 'category_editing']);
|
||||
marker.mockClear();
|
||||
await request(app).post('/categories/reorder').send({ event_id: 1, orderedIds: [2, 1] }).expect(200); // same override again
|
||||
expect(recorded()).toEqual([]);
|
||||
await request(app).delete('/categories/reorder/1').expect(200);
|
||||
expect(recorded()).toEqual(['category_editing']);
|
||||
});
|
||||
test('settings compare persisted values, not timestamps, JSON order or defaults materialized as rows', async () => {
|
||||
await db('app_settings').insert({ setting_key: 'theme_config', setting_value: JSON.stringify({ a: 1, b: 2 }) });
|
||||
expect(await settingsChanged(db, { theme_config: { b: 2, a: 1 } }, ['theme_config'])).toBe(false);
|
||||
expect(await settingsChanged(db, { theme_config: { b: 2, a: 2 } }, ['theme_config'])).toBe(true);
|
||||
expect(await settingsChanged(db, { missing: 'fallback' }, ['missing'])).toBe(false);
|
||||
expect(await settingsChanged(db, { secret: 'PRIVATE' }, ['theme_config'])).toBe(false);
|
||||
expect(changedFields({ enabled: 1, updated_at: 'old' }, { enabled: true, updated_at: 'new' }, ['enabled'])).toBe(false);
|
||||
expect(changedFields({ body: '' }, { body: null }, ['body'])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const catalog = require('../../src/usage/features.v4.json');
|
||||
const inventory = require('../../../docs/usage-coverage.v4.json');
|
||||
const catalog = require('../../src/usage/features.v5.json');
|
||||
const inventory = require('../../../docs/usage-coverage.v5.json');
|
||||
const protocol = require('../../src/usage/schema.cjs');
|
||||
const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules');
|
||||
const { acceptedUpload, capabilityEvidence } = require('../../src/usage/capabilityEvidence');
|
||||
@@ -52,22 +52,22 @@ test('all current settings tabs have an explicit scope decision', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('v1/v2/v3 wire validation is immutable; v4 catalog, UI and translated descriptions agree', () => {
|
||||
test('v1/v2/v3 wire validation is immutable; v5 catalog, UI and translated descriptions agree', () => {
|
||||
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex'))
|
||||
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
|
||||
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v2'].properties)).digest('hex'))
|
||||
.toBe('159821cf45c1951016d33a4ed9ca55a0a7ee1b60dd715b803fcfed33e5c8a846');
|
||||
expect(protocol.FEATURE_KEYS).toHaveLength(86);
|
||||
expect(protocol.FEATURE_KEYS).toHaveLength(87);
|
||||
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v3'].properties)).digest('hex'))
|
||||
.toBe('93214702c79f47823f154544ebad6612dd313604f69e60b86de4c0e4c904571a');
|
||||
expect(protocol.FEATURE_KEYS).toContain('gallery_downloads_restricted');
|
||||
expect(protocol.FEATURE_KEYS).not.toContain('gallery_downloads');
|
||||
expect(protocol.ALL_FEATURE_KEYS).toHaveLength(87);
|
||||
expect(protocol.ALL_FEATURE_KEYS).toHaveLength(94);
|
||||
expect(protocol.ALL_FEATURE_KEYS).toContain('gallery_downloads');
|
||||
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
|
||||
expect(inventory.configuration_only).toHaveLength(23);
|
||||
const frontend = path.resolve(__dirname, '../../../frontend');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v4.json')))).toEqual(catalog);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v5.json')))).toEqual(catalog);
|
||||
// The catalog is source, and source is English only: its strings are the
|
||||
// en locale verbatim. Every other language lives in its locale file and
|
||||
// must cover every key and field, but says whatever its translator chose.
|
||||
@@ -85,11 +85,11 @@ test('v1/v2/v3 wire validation is immutable; v4 catalog, UI and translated descr
|
||||
});
|
||||
|
||||
test('every used field has either a fixed route rule or explicit trusted success evidence', () => {
|
||||
const explicit = ['custom_css', 'oauth', 'smtp', 'email_webhook', 'whatsapp', 'incoming_mail',
|
||||
const explicit = ['cms_content_editing', 'email_template_editing', 'email_template_delivery', 'branding_editing', 'seo_editing', 'event_type_editing', 'category_editing', 'custom_css', 'oauth', 'smtp', 'email_webhook', 'whatsapp', 'incoming_mail',
|
||||
'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage', 's3_backups', 'api_integration', 'photo_xmp_export', 'photo_replacement', 'photo_admin_marks', 'crm_invoice_import', 'crm_combined_billing', 'crm_monthly_billing_manual', 'crm_document_conversion'];
|
||||
const covered = new Set([...explicit, ...RULES_V2.flatMap(([, , keys]) => keys)]);
|
||||
expect(protocol.FEATURE_KEYS.filter((key) => protocol.observesUse(key)).filter((key) => !covered.has(key))).toEqual([]);
|
||||
for (const key of covered) expect(protocol.observesUse(key)).toBe(true);
|
||||
for (const key of covered) expect(protocol.ALL_FEATURES[key].used).toBeTruthy();
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
||||
@@ -7,13 +7,13 @@ const signHistorical = (packet, id) => {
|
||||
return { ...signed, signature: crypto.sign(null, Buffer.from(p.canonical(signed)), id.private_key).toString('base64url') };
|
||||
};
|
||||
|
||||
describe.each(['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4'])('%s receiver compatibility never loosens the PicPeak sender', version => {
|
||||
describe.each(['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4', 'usage.v5'])('%s receiver compatibility never loosens the PicPeak sender', version => {
|
||||
test('complete original reports still sign and verify', () => {
|
||||
const id = p.generateIdentity();
|
||||
const packet = p.makePacket(id, 'report', 1, {
|
||||
picpeak_version: '1.0.0', report_date: '2026-09-06', generated_at: new Date(now).toISOString(),
|
||||
features: p.emptyFeatures(version), gallery_layouts: [],
|
||||
...(['usage.v3', 'usage.v4'].includes(version) ? { inventory: { galleries: 0, photos: 0 } } : {}),
|
||||
...(['usage.v3', 'usage.v4', 'usage.v5'].includes(version) ? { inventory: { galleries: 0, photos: 0 } } : {}),
|
||||
}, version);
|
||||
const envelope = p.signPacket(packet, id, new Date(now));
|
||||
expect(p.verifyEnvelope(envelope, now)).toEqual(packet);
|
||||
|
||||
@@ -67,6 +67,14 @@ test('the collector has no way in: no inbound route and no scheduled pull', () =
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
|
||||
if (entry.name === 'productUsageService.js') continue;
|
||||
if (entry.name === 'emailProcessor.js') {
|
||||
// v5 explicitly consents to one local background-mail bit, never a send
|
||||
// to the collector. No mail details may be passed into the usage API.
|
||||
const email = fs.readFileSync(path.join(dir, entry.name), 'utf8');
|
||||
expect(email).toContain(".markUsed(['email_template_delivery'])");
|
||||
expect(email).not.toMatch(/productUsageService'\)\.(?:tick|enable|command|deliver)/);
|
||||
continue;
|
||||
}
|
||||
expect(fs.readFileSync(path.join(dir, entry.name), 'utf8'))
|
||||
.not.toContain('productUsageService');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
const knex = require('knex');
|
||||
jest.mock('nodemailer', () => ({ createTransport: jest.fn() }));
|
||||
jest.mock('../../src/services/emailWebhookTransport', () => ({ isEnabled: jest.fn(), send: jest.fn() }));
|
||||
jest.mock('../../src/services/productUsageService', () => ({ markUsed: jest.fn().mockResolvedValue() }));
|
||||
jest.mock('../../src/services/businessProfileService', () => ({ getEmailSignature: jest.fn(async () => null) }));
|
||||
|
||||
describe('template delivery is a coarse transport-acceptance bit', () => {
|
||||
let db, sendTemplateEmail, queueEmail, processEmailQueue;
|
||||
const sendMail = jest.fn();
|
||||
const webhook = require('../../src/services/emailWebhookTransport');
|
||||
const marker = require('../../src/services/productUsageService').markUsed;
|
||||
beforeAll(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
await db.schema.createTable('email_configs', t => {
|
||||
t.increments('id'); for (const key of ['smtp_host', 'smtp_user', 'smtp_pass', 'from_name', 'from_email']) t.string(key);
|
||||
t.integer('smtp_port'); t.boolean('smtp_secure'); t.boolean('tls_reject_unauthorized');
|
||||
});
|
||||
await db('email_configs').insert({ smtp_host: 'smtp.example.test', smtp_port: 587, from_email: '[email protected]' });
|
||||
await db.schema.createTable('email_templates', t => { t.increments('id'); t.string('template_key'); t.text('subject'); t.text('body_html'); });
|
||||
await db('email_templates').insert({ template_key: 'PRIVATE-template', subject: 'PRIVATE subject', body_html: '<p>PRIVATE body</p>' });
|
||||
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
|
||||
await db.schema.createTable('email_queue', t => {
|
||||
t.increments('id'); t.integer('event_id'); t.integer('campaign_id'); t.string('recipient_email'); t.string('email_type');
|
||||
t.text('email_data'); t.string('status'); t.integer('retry_count'); t.text('error_message'); t.text('rendered_html');
|
||||
t.timestamp('scheduled_at'); t.timestamp('created_at'); t.timestamp('sent_at');
|
||||
});
|
||||
require('nodemailer').createTransport.mockReturnValue({ sendMail, verify: jest.fn(async () => true) });
|
||||
({ sendTemplateEmail, queueEmail, processEmailQueue } = require('../../src/services/emailProcessor'));
|
||||
});
|
||||
beforeEach(() => {
|
||||
marker.mockClear(); marker.mockResolvedValue(); webhook.isEnabled.mockReturnValue(false);
|
||||
sendMail.mockReset(); sendMail.mockResolvedValue({ messageId: 'PRIVATE-message', accepted: ['[email protected]'] });
|
||||
});
|
||||
afterAll(() => db.destroy());
|
||||
const send = options => sendTemplateEmail('[email protected]', 'PRIVATE-template', { __language: 'en' }, options);
|
||||
test('successful real SMTP send transmits only the fixed capability key', async () => {
|
||||
await send();
|
||||
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
|
||||
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
|
||||
});
|
||||
test('rejected recipients, failed sends, missing templates and explicit tests do not count', async () => {
|
||||
await send({ usageEligible: false });
|
||||
sendMail.mockResolvedValueOnce({ messageId: 'private', accepted: [] }); await send();
|
||||
sendMail.mockRejectedValueOnce(new Error('mail failed')); await expect(send()).rejects.toThrow('mail failed');
|
||||
await expect(sendTemplateEmail('[email protected]', 'missing', { __language: 'en' })).rejects.toThrow('not found');
|
||||
expect(marker).not.toHaveBeenCalled();
|
||||
});
|
||||
test('a queued test message keeps its exclusion through the queue processor; a queued real one counts', async () => {
|
||||
await queueEmail(null, '[email protected]', 'PRIVATE-template', { __language: 'en' }, { usageEligible: false });
|
||||
await processEmailQueue();
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(marker).not.toHaveBeenCalled();
|
||||
await queueEmail(null, '[email protected]', 'PRIVATE-template', { __language: 'en' });
|
||||
await processEmailQueue();
|
||||
expect(sendMail).toHaveBeenCalledTimes(2);
|
||||
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
|
||||
expect(await db('email_queue').where('status', 'sent').count({ n: '*' }).first()).toMatchObject({ n: 2 });
|
||||
});
|
||||
test('a workflow test run (engine.testRun, __test) queues a test message; a real run counts', async () => {
|
||||
require('../../src/services/workflows/actions');
|
||||
const sendEmail = require('../../src/services/workflows/registry').getAction('send_email');
|
||||
const ctx = (vars) => ({ node: { config: { to: '[email protected]', emailType: 'PRIVATE-template', recipientClass: 'admin' } }, vars });
|
||||
await sendEmail(ctx({ __test: true, emailData: { __language: 'en' } }));
|
||||
await processEmailQueue();
|
||||
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(marker).not.toHaveBeenCalled();
|
||||
await sendEmail(ctx({ emailData: { __language: 'en' } }));
|
||||
await processEmailQueue();
|
||||
expect(sendMail).toHaveBeenCalledTimes(2);
|
||||
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
|
||||
});
|
||||
test('webhook success counts; failure does not; marker failure never retries successful mail', async () => {
|
||||
webhook.isEnabled.mockReturnValue(true); webhook.send.mockResolvedValue({ messageId: 'private' });
|
||||
marker.mockRejectedValueOnce(new Error('usage unavailable'));
|
||||
await expect(send()).resolves.toMatchObject({ success: true });
|
||||
expect(webhook.send).toHaveBeenCalledTimes(1);
|
||||
marker.mockClear(); webhook.send.mockRejectedValueOnce(new Error('webhook failed'));
|
||||
await expect(send()).rejects.toThrow('webhook failed');
|
||||
expect(marker).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
const { changedEvidence } = require('../usage/adoptionEvidence');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
@@ -144,6 +145,8 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
if (!updated.use_external_url) changedEvidence(res, 'cms_content_editing', page, updated,
|
||||
['title_en', 'title_de', 'content_en', 'content_de']);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
logger.error('Error updating CMS page:', error);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const { changedEvidence } = require('../usage/adoptionEvidence');
|
||||
const { capabilityEvidence } = require('../usage/capabilityEvidence');
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
||||
@@ -123,6 +125,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
capabilityEvidence(res, 'category_editing');
|
||||
res.json(category);
|
||||
} catch (error) {
|
||||
logger.error('Error creating category:', error);
|
||||
@@ -206,6 +209,8 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
changedEvidence(res, 'category_editing', category, updated,
|
||||
['name', 'slug', 'hero_photo_id', 'allow_downloads', 'is_folder']);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
logger.error('Error updating category:', error);
|
||||
@@ -260,6 +265,7 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
changedEvidence(res, 'category_editing', category, updated, ['hero_photo_id']);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
logger.error('Error updating category hero:', error);
|
||||
@@ -294,6 +300,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
capabilityEvidence(res, 'category_editing');
|
||||
res.json({ message: 'Category deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting category:', error);
|
||||
@@ -346,12 +353,14 @@ router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
|
||||
return res.status(400).json({ error: 'One or more categories are not available for this event' });
|
||||
}
|
||||
|
||||
const before = await db('event_category_order').where('event_id', eventId).orderBy('position', 'asc').pluck('category_id');
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('event_category_order').where('event_id', eventId).del();
|
||||
await trx('event_category_order').insert(
|
||||
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
|
||||
);
|
||||
});
|
||||
changedEvidence(res, 'category_editing', { order: before }, { order: orderedIds }, ['order']);
|
||||
|
||||
// Log activity after commit (avoids a SQLite in-transaction global write).
|
||||
await logActivity('event_category_order_set',
|
||||
@@ -371,7 +380,8 @@ router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
|
||||
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.eventId, 10);
|
||||
await db('event_category_order').where('event_id', eventId).del();
|
||||
const removed = await db('event_category_order').where('event_id', eventId).del();
|
||||
if (removed > 0) capabilityEvidence(res, 'category_editing');
|
||||
|
||||
await logActivity('event_category_order_reset',
|
||||
{ eventId },
|
||||
@@ -400,7 +410,8 @@ router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
|
||||
|
||||
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
|
||||
|
||||
const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id');
|
||||
const globals = await db('photo_categories').where('is_global', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc').orderBy('name', 'asc').pluck('id');
|
||||
const globalsSet = new Set(globals);
|
||||
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
|
||||
if (invalid.length > 0) {
|
||||
@@ -423,6 +434,7 @@ router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
|
||||
.where('is_global', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.orderBy('name', 'asc');
|
||||
changedEvidence(res, 'category_editing', { order: globals }, { order: categories.map((category) => category.id) }, ['order']);
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error reordering global categories:', error);
|
||||
|
||||
@@ -443,7 +443,7 @@ router.post(
|
||||
const frontendUrl = await getAbsoluteFrontendUrl(req);
|
||||
const payload = await buildPayloadFor(req.body.templateKey, req.admin.id, frontendUrl);
|
||||
|
||||
await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload);
|
||||
await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload, { usageEligible: false });
|
||||
|
||||
return successResponse(res, {
|
||||
sent: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { changedFields } = require('../usage/adoptionEvidence');
|
||||
const express = require('express');
|
||||
const { capabilityEvidence } = require('../usage/capabilityEvidence');
|
||||
const nodemailer = require('nodemailer');
|
||||
@@ -1029,6 +1030,7 @@ router.put('/templates/:key', [
|
||||
return res.status(400).json({ error: 'translations object is required' });
|
||||
}
|
||||
|
||||
let contentChanged = false;
|
||||
// Upsert each language translation
|
||||
for (const [language, data] of Object.entries(translations)) {
|
||||
if (!data || typeof data !== 'object') continue;
|
||||
@@ -1044,6 +1046,7 @@ router.put('/templates/:key', [
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
contentChanged ||= changedFields(existing, row, ['subject', 'body_html', 'body_text']);
|
||||
if (existing) {
|
||||
await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
@@ -1091,6 +1094,7 @@ router.put('/templates/:key', [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
if (contentChanged) capabilityEvidence(res, 'email_template_editing');
|
||||
res.json({ message: 'Email template updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update email template');
|
||||
@@ -1178,6 +1182,8 @@ router.post('/templates', [
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username });
|
||||
|
||||
if (Object.values(translations).some(content => content && changedFields({}, content,
|
||||
['subject', 'body_html', 'body_text']))) capabilityEvidence(res, 'email_template_editing');
|
||||
return res.status(201).json({ template_key: templateKey, id: templateId });
|
||||
} catch (error) {
|
||||
return errorResponse(res, error, 500, 'Failed to create email template');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const { changedEvidence } = require('../usage/adoptionEvidence');
|
||||
const { capabilityEvidence } = require('../usage/capabilityEvidence');
|
||||
/**
|
||||
* Admin Event Types Routes
|
||||
* CRUD operations for managing customizable event types
|
||||
@@ -123,6 +125,7 @@ router.post('/', adminAuth, requirePermission('event_types.manage'), [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
capabilityEvidence(res, 'event_type_editing');
|
||||
res.status(201).json(eventType);
|
||||
} catch (error) {
|
||||
logger.error('Error creating event type:', { error: error.message });
|
||||
@@ -163,7 +166,10 @@ router.put('/:id', adminAuth, requirePermission('event_types.manage'), [
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const before = await eventTypeService.getEventTypeById(parseInt(id));
|
||||
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
|
||||
changedEvidence(res, 'event_type_editing', before, eventType,
|
||||
['name', 'slug_prefix', 'emoji', 'theme_preset', 'theme_config', 'display_order', 'is_active']);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_type_updated',
|
||||
@@ -210,6 +216,7 @@ router.delete('/:id', adminAuth, requirePermission('event_types.manage'), [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
capabilityEvidence(res, 'event_type_editing');
|
||||
res.json({ message: 'Event type deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting event type:', { error: error.message });
|
||||
@@ -240,7 +247,9 @@ router.post('/reorder', adminAuth, requirePermission('event_types.manage'), [
|
||||
}
|
||||
|
||||
const { orderedIds } = req.body;
|
||||
const before = (await eventTypeService.getAllEventTypes()).map((type) => type.id);
|
||||
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
|
||||
changedEvidence(res, 'event_type_editing', { order: before }, { order: eventTypes.map((type) => type.id) }, ['order']);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_types_reordered',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { capabilityEvidence } = require('../../usage/capabilityEvidence');
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Exports a register function; ./index.js calls the sub-routers in the original
|
||||
// registration order so Express route matching is unchanged.
|
||||
@@ -89,6 +90,7 @@ module.exports = (router) => {
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
capabilityEvidence(res, 'branding_editing');
|
||||
res.json({
|
||||
message: 'Event logo uploaded successfully',
|
||||
hero_logo_url: logoUrl
|
||||
@@ -135,6 +137,7 @@ module.exports = (router) => {
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
if (event.hero_logo_url || event.hero_logo_path) capabilityEvidence(res, 'branding_editing');
|
||||
res.json({ message: 'Event logo removed successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to delete event logo');
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
const { settingsChanged } = require('../usage/adoptionEvidence');
|
||||
const { capabilityEvidence } = require('../usage/capabilityEvidence');
|
||||
const SEO_USAGE_KEYS = ['seo_allow_indexing', 'seo_block_ai_crawlers', 'seo_block_social_bots',
|
||||
'seo_blocked_ai_agents', 'seo_custom_rules', 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', 'seo_sitemap_url'];
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
@@ -1036,6 +1040,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment })
|
||||
};
|
||||
|
||||
const brandingUpdates = Object.fromEntries(Object.entries(brandingSettings).map(([key, value]) => [`branding_${key}`, value]));
|
||||
const brandingChanged = await settingsChanged(db, brandingUpdates, Object.keys(brandingUpdates));
|
||||
|
||||
// Handle favicon deletion if empty string or null is provided
|
||||
if (favicon_url === '' || favicon_url === null || favicon_url === undefined) {
|
||||
// Get current favicon path to delete file
|
||||
@@ -1125,6 +1132,7 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
metadata: JSON.stringify({ company_name })
|
||||
});
|
||||
|
||||
if (brandingChanged) capabilityEvidence(res, 'branding_editing');
|
||||
clearPublicSiteCache();
|
||||
|
||||
// Check if watermark settings changed and trigger regeneration
|
||||
@@ -1229,6 +1237,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
capabilityEvidence(res, 'branding_editing');
|
||||
res.json({
|
||||
message: 'Logo uploaded successfully',
|
||||
logoUrl: publicPath
|
||||
@@ -1247,6 +1256,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
|
||||
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
|
||||
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
|
||||
|
||||
const logoChanged = await settingsChanged(db, { [pathKey]: '', [urlKey]: '' }, [pathKey, urlKey]);
|
||||
const pathSetting = await db('app_settings').where('setting_key', pathKey).first();
|
||||
if (pathSetting && pathSetting.setting_value) {
|
||||
try {
|
||||
@@ -1261,6 +1271,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
|
||||
.whereIn('setting_key', [pathKey, urlKey])
|
||||
.update({ setting_value: JSON.stringify(''), updated_at: new Date() });
|
||||
|
||||
if (logoChanged) capabilityEvidence(res, 'branding_editing');
|
||||
res.json({ message: 'Logo removed' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to remove logo');
|
||||
@@ -1346,6 +1357,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
watermarkRegenerationStarted = true;
|
||||
}
|
||||
|
||||
capabilityEvidence(res, 'branding_editing');
|
||||
res.json({
|
||||
message: 'Watermark logo uploaded successfully',
|
||||
watermarkLogoUrl: publicPath,
|
||||
@@ -1360,6 +1372,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const themeSettings = req.body;
|
||||
const themeChanged = await settingsChanged(db, { theme_config: themeSettings }, ['theme_config']);
|
||||
|
||||
// Save theme settings
|
||||
await db('app_settings')
|
||||
@@ -1386,6 +1399,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
if (themeChanged) capabilityEvidence(res, 'branding_editing');
|
||||
res.json({ message: 'Theme settings updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update theme settings');
|
||||
@@ -1709,6 +1723,7 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
|
||||
}
|
||||
}
|
||||
|
||||
const seoChanged = await settingsChanged(db, settings, SEO_USAGE_KEYS);
|
||||
// Update or insert each setting
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await db('app_settings')
|
||||
@@ -1738,6 +1753,7 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
|
||||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||||
});
|
||||
|
||||
if (seoChanged) capabilityEvidence(res, 'seo_editing');
|
||||
res.json({ message: 'SEO settings updated successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update SEO settings');
|
||||
@@ -2035,6 +2051,7 @@ router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUp
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
capabilityEvidence(res, 'branding_editing');
|
||||
res.json({ faviconUrl });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to upload favicon');
|
||||
|
||||
@@ -82,7 +82,7 @@ router.post(
|
||||
router.post(
|
||||
'/consent',
|
||||
wrap(async (req, res) => {
|
||||
if (!req.body || Object.keys(req.body).length !== 1 || !['usage.v2', 'usage.v3', 'usage.v4'].includes(schemaForConsent(req.body.consent_version)))
|
||||
if (!req.body || Object.keys(req.body).length !== 1 || !schemaForConsent(req.body.consent_version) || req.body?.consent_version === 'usage-consent.v1')
|
||||
throw new ValidationError('Explicit usage consent is required');
|
||||
res.json(await service.command('consent', { consent_version: req.body.consent_version }));
|
||||
})
|
||||
|
||||
@@ -906,7 +906,7 @@ async function buildSignatureTextFor(language) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTemplateEmail(to, templateKey, variables) {
|
||||
async function sendTemplateEmail(to, templateKey, variables, { usageEligible = true } = {}) {
|
||||
try {
|
||||
// Webhook transport (#1225) replaces SMTP entirely when configured, so an
|
||||
// instance using it has no SMTP settings to initialise and must not be
|
||||
@@ -987,6 +987,17 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
? await emailWebhookTransport.send(mail)
|
||||
: await transporter.sendMail(mail);
|
||||
|
||||
// Transport acceptance is the measured event, not rendering or inbox
|
||||
// delivery. The service accepts only the fixed bit under confirmed v5
|
||||
// consent; marker failure must never retry an already-sent message.
|
||||
if (usageEligible && (viaWebhook || info.accepted?.length > 0)) {
|
||||
try {
|
||||
await require('./productUsageService').markUsed(['email_template_delivery']);
|
||||
} catch {
|
||||
logger.warn('Product usage mail marker could not be recorded');
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
|
||||
// Return the rendered HTML so the queue processor can persist the ACTUAL
|
||||
// sent body (email_queue.rendered_html) for the Project Overview preview.
|
||||
@@ -1275,7 +1286,8 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
|
||||
sendResult = await sendTemplateEmail(
|
||||
email.recipient_email,
|
||||
email.email_type,
|
||||
emailData
|
||||
emailData,
|
||||
{ usageEligible: emailData.__usageEligible !== false }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1436,6 +1448,9 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options
|
||||
try {
|
||||
// Add eventId to emailData for language detection
|
||||
emailData.eventId = eventId;
|
||||
// An explicit test message (dev tools' send-test-email) must not count as
|
||||
// template delivery when the queue processor sends it later.
|
||||
if (options.usageEligible === false) emailData.__usageEligible = false;
|
||||
const row = {
|
||||
event_id: eventId,
|
||||
recipient_email: recipientEmail,
|
||||
|
||||
@@ -231,7 +231,7 @@ async function sendTestUpdateNotification() {
|
||||
current_version: updateInfo.current,
|
||||
channel: channelLabel,
|
||||
recipient_email: email
|
||||
});
|
||||
}, { usageEligible: false });
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
|
||||
@@ -53,7 +53,8 @@ registry.registerAction('send_email', async (ctx) => {
|
||||
|
||||
// INTERNAL/admin = immediate; EXTERNAL/customer = business-hours floor.
|
||||
const respectBusinessHours = !isInternal;
|
||||
await emailProcessor.queueEmail(eventId, to, emailType, emailData, { respectBusinessHours });
|
||||
// A workflow test run (engine.testRun, __test) is a test message for usage.
|
||||
await emailProcessor.queueEmail(eventId, to, emailType, emailData, { respectBusinessHours, usageEligible: !ctx.vars?.__test });
|
||||
return { sent_to: to, recipientClass, respectBusinessHours };
|
||||
});
|
||||
|
||||
@@ -120,7 +121,7 @@ registry.registerAction('escalate_to_collections', async (ctx) => {
|
||||
due_date: invoice.due_date ? String(invoice.due_date).slice(0, 10) : '',
|
||||
reminder_level: invoice.reminder_level || 0,
|
||||
attachments,
|
||||
}, { respectBusinessHours: false }); // internal/admin → immediate
|
||||
}, { respectBusinessHours: false, usageEligible: !ctx.vars?.__test }); // internal/admin → immediate
|
||||
|
||||
return { collections_handoff_to: adminEmail, outstanding };
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ const {
|
||||
verifyEnvelope,
|
||||
digest,
|
||||
canonical,
|
||||
FEATURE_KEYS,
|
||||
ALL_FEATURE_KEYS,
|
||||
LEGACY_FEATURE_KEYS,
|
||||
CATALOG,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
@@ -796,7 +796,7 @@ class UsageService {
|
||||
|
||||
async markUsed(features, { destinationBackup = false, legacyFeatures } = {}) {
|
||||
let allowed = [...new Set([...features, ...(legacyFeatures || [])])].filter((f) =>
|
||||
FEATURE_KEYS.includes(f)
|
||||
ALL_FEATURE_KEYS.includes(f)
|
||||
);
|
||||
if (!allowed.length) return;
|
||||
// Single-transaction status check prevents opt-out racing a late marker.
|
||||
@@ -960,7 +960,7 @@ class UsageService {
|
||||
generated_at: now,
|
||||
features: expanded,
|
||||
gallery_layouts: [...layouts].sort(),
|
||||
...(['usage.v3', 'usage.v4'].includes(version) ? { inventory: await require('./inventorySnapshot').inventorySnapshot(this.db) } : {})
|
||||
...(['usage.v3', 'usage.v4', 'usage.v5'].includes(version) ? { inventory: await require('./inventorySnapshot').inventorySnapshot(this.db) } : {})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
const { isDeepStrictEqual } = require('node:util');
|
||||
const { capabilityEvidence } = require('./capabilityEvidence');
|
||||
|
||||
// Compare values already handled locally by an admin operation. Never retain
|
||||
// these values, hashes, IDs or a before/after record in the usage subsystem.
|
||||
function normalized(value) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) {
|
||||
try { return normalized(JSON.parse(value)); } catch { return value; }
|
||||
}
|
||||
// Array.from, not .map: a row array from the sqlite binding belongs to the
|
||||
// outer realm under Jest and isDeepStrictEqual rejects it on prototype alone.
|
||||
if (Array.isArray(value)) return Array.from(value, normalized);
|
||||
if (value && typeof value === 'object') return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, normalized(item)])
|
||||
);
|
||||
return value;
|
||||
}
|
||||
function changedFields(before, after, fields) {
|
||||
return fields.some(key => {
|
||||
if (after[key] === undefined) return false;
|
||||
let left = before?.[key], right = after[key];
|
||||
if (typeof left === 'boolean' && (right === 0 || right === 1)) right = Boolean(right);
|
||||
if (typeof right === 'boolean' && (left === 0 || left === 1)) left = Boolean(left);
|
||||
return !isDeepStrictEqual(normalized(left), normalized(right));
|
||||
});
|
||||
}
|
||||
function changedEvidence(res, key, before, after, fields) {
|
||||
if (changedFields(before, after, fields)) capabilityEvidence(res, key);
|
||||
}
|
||||
|
||||
// Restrict comparisons to named product settings. Missing rows are unknown,
|
||||
// not evidence of customization: persisting a fallback for the first time must
|
||||
// not turn a default-only installation into an observed customization.
|
||||
async function settingsChanged(db, updates, allowedKeys) {
|
||||
const keys = allowedKeys.filter(key => updates[key] !== undefined);
|
||||
if (!keys.length) return false;
|
||||
try {
|
||||
const rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value');
|
||||
return rows.some(row => {
|
||||
let previous = row.setting_value;
|
||||
try { previous = JSON.parse(previous); } catch { /* legacy plain value */ }
|
||||
return changedFields({ value: previous }, { value: updates[row.setting_key] }, ['value']);
|
||||
});
|
||||
} catch {
|
||||
// Optional evidence must not prevent the product operation from running.
|
||||
require('../utils/logger').warn('Product usage settings comparison unavailable');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
module.exports = { changedFields, changedEvidence, settingsChanged };
|
||||
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
const { FEATURE_KEYS, observesUse } = require('./schema.cjs');
|
||||
const { ALL_FEATURE_KEYS, ALL_FEATURES } = require('./schema.cjs');
|
||||
|
||||
// Trusted route handlers call this AFTER their business operation succeeds.
|
||||
// Only fixed, allowlisted keys reach finish middleware. It still requires an
|
||||
@@ -7,7 +7,7 @@ const { FEATURE_KEYS, observesUse } = require('./schema.cjs');
|
||||
function capabilityEvidence(res, ...keys) {
|
||||
res.locals.productUsageFeatures = [...new Set([
|
||||
...(res.locals.productUsageFeatures || []),
|
||||
...keys.filter((key) => FEATURE_KEYS.includes(key) && observesUse(key))
|
||||
...keys.filter((key) => ALL_FEATURE_KEYS.includes(key) && ALL_FEATURES[key]?.used)
|
||||
])];
|
||||
}
|
||||
function acceptedUpload(res, { video = false, raw = false, s3 = false } = {}) {
|
||||
|
||||
@@ -85,7 +85,7 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage
|
||||
gallery_guest_uploads: 'allow_user_uploads',
|
||||
gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads'
|
||||
})) result[key].configured = await enabled('events', column);
|
||||
if (version === 'usage.v4') {
|
||||
if (['usage.v4', 'usage.v5'].includes(version)) {
|
||||
result.gallery_downloads_restricted.configured = await exists('events', ['allow_downloads'], (query) =>
|
||||
query.where('allow_downloads', formatBoolean(false)));
|
||||
} else {
|
||||
@@ -122,7 +122,7 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage
|
||||
query.where({ feedback_enabled: formatBoolean(true), [column]: formatBoolean(true) }));
|
||||
result.gallery_guest_accounts.configured = await exists('event_feedback_settings', ['feedback_enabled', 'identity_mode'], (query) =>
|
||||
query.where('feedback_enabled', formatBoolean(true)).whereIn('identity_mode', ['guest', 'shared']));
|
||||
if (['usage.v3', 'usage.v4'].includes(version)) {
|
||||
if (['usage.v3', 'usage.v4', 'usage.v5'].includes(version)) {
|
||||
result.gallery_folders.configured = await exists('photo_categories', ['is_folder', 'event_id'], (query) =>
|
||||
query.where('is_folder', formatBoolean(true)).where((q) => q.whereNull('event_id').orWhereIn('event_id', db('events').select('id'))));
|
||||
result.transfer_upload_links.configured = Boolean(effective.transfers) && await exists('transfers',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,10 @@
|
||||
|
||||
// Vendored byte-identical in PicPeak. Existing wire versions stay immutable;
|
||||
// every expansion requires explicit consent to its own version.
|
||||
const CATALOG = require("./features.v4.json");
|
||||
const CATALOGS = { "usage.v2": require("./features.v2.json"), "usage.v3": require("./features.v3.json"), "usage.v4": CATALOG };
|
||||
const CONSENT_VERSIONS = { "usage.v1": "usage-consent.v1", "usage.v2": "usage-consent.v2", "usage.v3": "usage-consent.v3", "usage.v4": "usage-consent.v4" };
|
||||
const CURRENT_SCHEMA_VERSION = "usage.v4";
|
||||
const CATALOG = require("./features.v5.json");
|
||||
const CATALOGS = { "usage.v2": require("./features.v2.json"), "usage.v3": require("./features.v3.json"), "usage.v4": require("./features.v4.json"), "usage.v5": CATALOG };
|
||||
const CONSENT_VERSIONS = { "usage.v1": "usage-consent.v1", "usage.v2": "usage-consent.v2", "usage.v3": "usage-consent.v3", "usage.v4": "usage-consent.v4", "usage.v5": "usage-consent.v5" };
|
||||
const CURRENT_SCHEMA_VERSION = "usage.v5";
|
||||
const CURRENT_CONSENT_VERSION = CONSENT_VERSIONS[CURRENT_SCHEMA_VERSION];
|
||||
const schemaForConsent = (consent) => Object.keys(CONSENT_VERSIONS).find((version) => CONSENT_VERSIONS[version] === consent);
|
||||
const schemaRank = (version) => Object.keys(CONSENT_VERSIONS).indexOf(version);
|
||||
@@ -49,7 +49,7 @@ const report = (version) => object({
|
||||
key, object({ configured: boolean, ...(observesUse(key, version) ? { used: boolean } : {}) })
|
||||
]))),
|
||||
gallery_layouts: { type: "array", uniqueItems: true, maxItems: LAYOUTS.length, items: { enum: LAYOUTS } },
|
||||
...(["usage.v3", "usage.v4"].includes(version) ? { inventory: object(Object.fromEntries(INVENTORY_KEYS.map(key => [key,
|
||||
...(["usage.v3", "usage.v4", "usage.v5"].includes(version) ? { inventory: object(Object.fromEntries(INVENTORY_KEYS.map(key => [key,
|
||||
{ type: "integer", minimum: 0, maximum: MAX_INVENTORY_COUNT }
|
||||
]))) } : {}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user