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');
|
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) => {
|
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.v5' }, { consent_version, user: 'PRIVATE' }])
|
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);
|
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`).send(data).expect(400);
|
||||||
expect(service.command).not.toHaveBeenCalled();
|
expect(service.command).not.toHaveBeenCalled();
|
||||||
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`)
|
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 fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const catalog = require('../../src/usage/features.v4.json');
|
const catalog = require('../../src/usage/features.v5.json');
|
||||||
const inventory = require('../../../docs/usage-coverage.v4.json');
|
const inventory = require('../../../docs/usage-coverage.v5.json');
|
||||||
const protocol = require('../../src/usage/schema.cjs');
|
const protocol = require('../../src/usage/schema.cjs');
|
||||||
const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules');
|
const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules');
|
||||||
const { acceptedUpload, capabilityEvidence } = require('../../src/usage/capabilityEvidence');
|
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'))
|
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex'))
|
||||||
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
|
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
|
||||||
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v2'].properties)).digest('hex'))
|
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v2'].properties)).digest('hex'))
|
||||||
.toBe('159821cf45c1951016d33a4ed9ca55a0a7ee1b60dd715b803fcfed33e5c8a846');
|
.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'))
|
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v3'].properties)).digest('hex'))
|
||||||
.toBe('93214702c79f47823f154544ebad6612dd313604f69e60b86de4c0e4c904571a');
|
.toBe('93214702c79f47823f154544ebad6612dd313604f69e60b86de4c0e4c904571a');
|
||||||
expect(protocol.FEATURE_KEYS).toContain('gallery_downloads_restricted');
|
expect(protocol.FEATURE_KEYS).toContain('gallery_downloads_restricted');
|
||||||
expect(protocol.FEATURE_KEYS).not.toContain('gallery_downloads');
|
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.ALL_FEATURE_KEYS).toContain('gallery_downloads');
|
||||||
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
|
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
|
||||||
expect(inventory.configuration_only).toHaveLength(23);
|
expect(inventory.configuration_only).toHaveLength(23);
|
||||||
const frontend = path.resolve(__dirname, '../../../frontend');
|
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
|
// 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
|
// en locale verbatim. Every other language lives in its locale file and
|
||||||
// must cover every key and field, but says whatever its translator chose.
|
// 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', () => {
|
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'];
|
'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)]);
|
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([]);
|
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([
|
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') };
|
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', () => {
|
test('complete original reports still sign and verify', () => {
|
||||||
const id = p.generateIdentity();
|
const id = p.generateIdentity();
|
||||||
const packet = p.makePacket(id, 'report', 1, {
|
const packet = p.makePacket(id, 'report', 1, {
|
||||||
picpeak_version: '1.0.0', report_date: '2026-09-06', generated_at: new Date(now).toISOString(),
|
picpeak_version: '1.0.0', report_date: '2026-09-06', generated_at: new Date(now).toISOString(),
|
||||||
features: p.emptyFeatures(version), gallery_layouts: [],
|
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);
|
}, version);
|
||||||
const envelope = p.signPacket(packet, id, new Date(now));
|
const envelope = p.signPacket(packet, id, new Date(now));
|
||||||
expect(p.verifyEnvelope(envelope, now)).toEqual(packet);
|
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 })) {
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
|
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
|
||||||
if (entry.name === 'productUsageService.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'))
|
expect(fs.readFileSync(path.join(dir, entry.name), 'utf8'))
|
||||||
.not.toContain('productUsageService');
|
.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 express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
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 }
|
{ 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);
|
res.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating CMS page:', 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 express = require('express');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { safeValidationErrors } = require('../utils/routeHelpers');
|
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 }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
capabilityEvidence(res, 'category_editing');
|
||||||
res.json(category);
|
res.json(category);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating category:', 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 }
|
{ 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);
|
res.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating category:', 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 }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
changedEvidence(res, 'category_editing', category, updated, ['hero_photo_id']);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating category hero:', 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 }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
capabilityEvidence(res, 'category_editing');
|
||||||
res.json({ message: 'Category deleted successfully' });
|
res.json({ message: 'Category deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting category:', 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' });
|
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 db.transaction(async (trx) => {
|
||||||
await trx('event_category_order').where('event_id', eventId).del();
|
await trx('event_category_order').where('event_id', eventId).del();
|
||||||
await trx('event_category_order').insert(
|
await trx('event_category_order').insert(
|
||||||
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
|
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).
|
// Log activity after commit (avoids a SQLite in-transaction global write).
|
||||||
await logActivity('event_category_order_set',
|
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) => {
|
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), requireEventOwnership, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const eventId = parseInt(req.params.eventId, 10);
|
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',
|
await logActivity('event_category_order_reset',
|
||||||
{ eventId },
|
{ eventId },
|
||||||
@@ -400,7 +410,8 @@ router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
|
|||||||
|
|
||||||
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
|
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 globalsSet = new Set(globals);
|
||||||
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
|
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
|
||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
@@ -423,6 +434,7 @@ router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
|
|||||||
.where('is_global', formatBoolean(true))
|
.where('is_global', formatBoolean(true))
|
||||||
.orderBy('display_order', 'asc')
|
.orderBy('display_order', 'asc')
|
||||||
.orderBy('name', 'asc');
|
.orderBy('name', 'asc');
|
||||||
|
changedEvidence(res, 'category_editing', { order: globals }, { order: categories.map((category) => category.id) }, ['order']);
|
||||||
res.json(categories);
|
res.json(categories);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error reordering global categories:', error);
|
logger.error('Error reordering global categories:', error);
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ router.post(
|
|||||||
const frontendUrl = await getAbsoluteFrontendUrl(req);
|
const frontendUrl = await getAbsoluteFrontendUrl(req);
|
||||||
const payload = await buildPayloadFor(req.body.templateKey, req.admin.id, frontendUrl);
|
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, {
|
return successResponse(res, {
|
||||||
sent: true,
|
sent: true,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { changedFields } = require('../usage/adoptionEvidence');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { capabilityEvidence } = require('../usage/capabilityEvidence');
|
const { capabilityEvidence } = require('../usage/capabilityEvidence');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
@@ -1029,6 +1030,7 @@ router.put('/templates/:key', [
|
|||||||
return res.status(400).json({ error: 'translations object is required' });
|
return res.status(400).json({ error: 'translations object is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let contentChanged = false;
|
||||||
// Upsert each language translation
|
// Upsert each language translation
|
||||||
for (const [language, data] of Object.entries(translations)) {
|
for (const [language, data] of Object.entries(translations)) {
|
||||||
if (!data || typeof data !== 'object') continue;
|
if (!data || typeof data !== 'object') continue;
|
||||||
@@ -1044,6 +1046,7 @@ router.put('/templates/:key', [
|
|||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
contentChanged ||= changedFields(existing, row, ['subject', 'body_html', 'body_text']);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await db('email_template_translations')
|
await db('email_template_translations')
|
||||||
.where({ template_id: template.id, language })
|
.where({ template_id: template.id, language })
|
||||||
@@ -1091,6 +1094,7 @@ router.put('/templates/:key', [
|
|||||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (contentChanged) capabilityEvidence(res, 'email_template_editing');
|
||||||
res.json({ message: 'Email template updated successfully' });
|
res.json({ message: 'Email template updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to update email template');
|
errorResponse(res, error, 500, 'Failed to update email template');
|
||||||
@@ -1178,6 +1182,8 @@ router.post('/templates', [
|
|||||||
null,
|
null,
|
||||||
{ type: 'admin', id: req.admin.id, name: req.admin.username });
|
{ 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 });
|
return res.status(201).json({ template_key: templateKey, id: templateId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(res, error, 500, 'Failed to create email template');
|
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
|
* Admin Event Types Routes
|
||||||
* CRUD operations for managing customizable event types
|
* 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 }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
capabilityEvidence(res, 'event_type_editing');
|
||||||
res.status(201).json(eventType);
|
res.status(201).json(eventType);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating event type:', { error: error.message });
|
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 { id } = req.params;
|
||||||
const updates = req.body;
|
const updates = req.body;
|
||||||
|
|
||||||
|
const before = await eventTypeService.getEventTypeById(parseInt(id));
|
||||||
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
|
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
|
// Log activity
|
||||||
await logActivity('event_type_updated',
|
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 }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
capabilityEvidence(res, 'event_type_editing');
|
||||||
res.json({ message: 'Event type deleted successfully' });
|
res.json({ message: 'Event type deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting event type:', { error: error.message });
|
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 { orderedIds } = req.body;
|
||||||
|
const before = (await eventTypeService.getAllEventTypes()).map((type) => type.id);
|
||||||
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
|
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
|
||||||
|
changedEvidence(res, 'event_type_editing', { order: before }, { order: eventTypes.map((type) => type.id) }, ['order']);
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await logActivity('event_types_reordered',
|
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).
|
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||||
// Exports a register function; ./index.js calls the sub-routers in the original
|
// Exports a register function; ./index.js calls the sub-routers in the original
|
||||||
// registration order so Express route matching is unchanged.
|
// 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 }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
capabilityEvidence(res, 'branding_editing');
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Event logo uploaded successfully',
|
message: 'Event logo uploaded successfully',
|
||||||
hero_logo_url: logoUrl
|
hero_logo_url: logoUrl
|
||||||
@@ -135,6 +137,7 @@ module.exports = (router) => {
|
|||||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
{ 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' });
|
res.json({ message: 'Event logo removed successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to delete event logo');
|
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 express = require('express');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -1036,6 +1040,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
|||||||
...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment })
|
...(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
|
// Handle favicon deletion if empty string or null is provided
|
||||||
if (favicon_url === '' || favicon_url === null || favicon_url === undefined) {
|
if (favicon_url === '' || favicon_url === null || favicon_url === undefined) {
|
||||||
// Get current favicon path to delete file
|
// 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 })
|
metadata: JSON.stringify({ company_name })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (brandingChanged) capabilityEvidence(res, 'branding_editing');
|
||||||
clearPublicSiteCache();
|
clearPublicSiteCache();
|
||||||
|
|
||||||
// Check if watermark settings changed and trigger regeneration
|
// 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()
|
updated_at: new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
capabilityEvidence(res, 'branding_editing');
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Logo uploaded successfully',
|
message: 'Logo uploaded successfully',
|
||||||
logoUrl: publicPath
|
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 pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
|
||||||
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
|
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();
|
const pathSetting = await db('app_settings').where('setting_key', pathKey).first();
|
||||||
if (pathSetting && pathSetting.setting_value) {
|
if (pathSetting && pathSetting.setting_value) {
|
||||||
try {
|
try {
|
||||||
@@ -1261,6 +1271,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
|
|||||||
.whereIn('setting_key', [pathKey, urlKey])
|
.whereIn('setting_key', [pathKey, urlKey])
|
||||||
.update({ setting_value: JSON.stringify(''), updated_at: new Date() });
|
.update({ setting_value: JSON.stringify(''), updated_at: new Date() });
|
||||||
|
|
||||||
|
if (logoChanged) capabilityEvidence(res, 'branding_editing');
|
||||||
res.json({ message: 'Logo removed' });
|
res.json({ message: 'Logo removed' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to remove logo');
|
errorResponse(res, error, 500, 'Failed to remove logo');
|
||||||
@@ -1346,6 +1357,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
|||||||
watermarkRegenerationStarted = true;
|
watermarkRegenerationStarted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
capabilityEvidence(res, 'branding_editing');
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Watermark logo uploaded successfully',
|
message: 'Watermark logo uploaded successfully',
|
||||||
watermarkLogoUrl: publicPath,
|
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) => {
|
router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const themeSettings = req.body;
|
const themeSettings = req.body;
|
||||||
|
const themeChanged = await settingsChanged(db, { theme_config: themeSettings }, ['theme_config']);
|
||||||
|
|
||||||
// Save theme settings
|
// Save theme settings
|
||||||
await db('app_settings')
|
await db('app_settings')
|
||||||
@@ -1386,6 +1399,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
|
|||||||
|
|
||||||
clearPublicSiteCache();
|
clearPublicSiteCache();
|
||||||
|
|
||||||
|
if (themeChanged) capabilityEvidence(res, 'branding_editing');
|
||||||
res.json({ message: 'Theme settings updated successfully' });
|
res.json({ message: 'Theme settings updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to update theme settings');
|
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
|
// Update or insert each setting
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
await db('app_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 })
|
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (seoChanged) capabilityEvidence(res, 'seo_editing');
|
||||||
res.json({ message: 'SEO settings updated successfully' });
|
res.json({ message: 'SEO settings updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to update SEO settings');
|
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 }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
capabilityEvidence(res, 'branding_editing');
|
||||||
res.json({ faviconUrl });
|
res.json({ faviconUrl });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to upload favicon');
|
errorResponse(res, error, 500, 'Failed to upload favicon');
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
'/consent',
|
'/consent',
|
||||||
wrap(async (req, res) => {
|
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');
|
throw new ValidationError('Explicit usage consent is required');
|
||||||
res.json(await service.command('consent', { consent_version: req.body.consent_version }));
|
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 {
|
try {
|
||||||
// Webhook transport (#1225) replaces SMTP entirely when configured, so an
|
// Webhook transport (#1225) replaces SMTP entirely when configured, so an
|
||||||
// instance using it has no SMTP settings to initialise and must not be
|
// 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 emailWebhookTransport.send(mail)
|
||||||
: await transporter.sendMail(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})`);
|
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
|
||||||
// Return the rendered HTML so the queue processor can persist the ACTUAL
|
// Return the rendered HTML so the queue processor can persist the ACTUAL
|
||||||
// sent body (email_queue.rendered_html) for the Project Overview preview.
|
// 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(
|
sendResult = await sendTemplateEmail(
|
||||||
email.recipient_email,
|
email.recipient_email,
|
||||||
email.email_type,
|
email.email_type,
|
||||||
emailData
|
emailData,
|
||||||
|
{ usageEligible: emailData.__usageEligible !== false }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1436,6 +1448,9 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options
|
|||||||
try {
|
try {
|
||||||
// Add eventId to emailData for language detection
|
// Add eventId to emailData for language detection
|
||||||
emailData.eventId = eventId;
|
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 = {
|
const row = {
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
recipient_email: recipientEmail,
|
recipient_email: recipientEmail,
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ async function sendTestUpdateNotification() {
|
|||||||
current_version: updateInfo.current,
|
current_version: updateInfo.current,
|
||||||
channel: channelLabel,
|
channel: channelLabel,
|
||||||
recipient_email: email
|
recipient_email: email
|
||||||
});
|
}, { usageEligible: false });
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ registry.registerAction('send_email', async (ctx) => {
|
|||||||
|
|
||||||
// INTERNAL/admin = immediate; EXTERNAL/customer = business-hours floor.
|
// INTERNAL/admin = immediate; EXTERNAL/customer = business-hours floor.
|
||||||
const respectBusinessHours = !isInternal;
|
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 };
|
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) : '',
|
due_date: invoice.due_date ? String(invoice.due_date).slice(0, 10) : '',
|
||||||
reminder_level: invoice.reminder_level || 0,
|
reminder_level: invoice.reminder_level || 0,
|
||||||
attachments,
|
attachments,
|
||||||
}, { respectBusinessHours: false }); // internal/admin → immediate
|
}, { respectBusinessHours: false, usageEligible: !ctx.vars?.__test }); // internal/admin → immediate
|
||||||
|
|
||||||
return { collections_handoff_to: adminEmail, outstanding };
|
return { collections_handoff_to: adminEmail, outstanding };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const {
|
|||||||
verifyEnvelope,
|
verifyEnvelope,
|
||||||
digest,
|
digest,
|
||||||
canonical,
|
canonical,
|
||||||
FEATURE_KEYS,
|
ALL_FEATURE_KEYS,
|
||||||
LEGACY_FEATURE_KEYS,
|
LEGACY_FEATURE_KEYS,
|
||||||
CATALOG,
|
CATALOG,
|
||||||
CURRENT_SCHEMA_VERSION,
|
CURRENT_SCHEMA_VERSION,
|
||||||
@@ -796,7 +796,7 @@ class UsageService {
|
|||||||
|
|
||||||
async markUsed(features, { destinationBackup = false, legacyFeatures } = {}) {
|
async markUsed(features, { destinationBackup = false, legacyFeatures } = {}) {
|
||||||
let allowed = [...new Set([...features, ...(legacyFeatures || [])])].filter((f) =>
|
let allowed = [...new Set([...features, ...(legacyFeatures || [])])].filter((f) =>
|
||||||
FEATURE_KEYS.includes(f)
|
ALL_FEATURE_KEYS.includes(f)
|
||||||
);
|
);
|
||||||
if (!allowed.length) return;
|
if (!allowed.length) return;
|
||||||
// Single-transaction status check prevents opt-out racing a late marker.
|
// Single-transaction status check prevents opt-out racing a late marker.
|
||||||
@@ -960,7 +960,7 @@ class UsageService {
|
|||||||
generated_at: now,
|
generated_at: now,
|
||||||
features: expanded,
|
features: expanded,
|
||||||
gallery_layouts: [...layouts].sort(),
|
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';
|
'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.
|
// Trusted route handlers call this AFTER their business operation succeeds.
|
||||||
// Only fixed, allowlisted keys reach finish middleware. It still requires an
|
// 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) {
|
function capabilityEvidence(res, ...keys) {
|
||||||
res.locals.productUsageFeatures = [...new Set([
|
res.locals.productUsageFeatures = [...new Set([
|
||||||
...(res.locals.productUsageFeatures || []),
|
...(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 } = {}) {
|
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_guest_uploads: 'allow_user_uploads',
|
||||||
gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads'
|
gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads'
|
||||||
})) result[key].configured = await enabled('events', column);
|
})) 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) =>
|
result.gallery_downloads_restricted.configured = await exists('events', ['allow_downloads'], (query) =>
|
||||||
query.where('allow_downloads', formatBoolean(false)));
|
query.where('allow_downloads', formatBoolean(false)));
|
||||||
} else {
|
} else {
|
||||||
@@ -122,7 +122,7 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage
|
|||||||
query.where({ feedback_enabled: formatBoolean(true), [column]: formatBoolean(true) }));
|
query.where({ feedback_enabled: formatBoolean(true), [column]: formatBoolean(true) }));
|
||||||
result.gallery_guest_accounts.configured = await exists('event_feedback_settings', ['feedback_enabled', 'identity_mode'], (query) =>
|
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']));
|
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) =>
|
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'))));
|
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',
|
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;
|
// Vendored byte-identical in PicPeak. Existing wire versions stay immutable;
|
||||||
// every expansion requires explicit consent to its own version.
|
// every expansion requires explicit consent to its own version.
|
||||||
const CATALOG = require("./features.v4.json");
|
const CATALOG = require("./features.v5.json");
|
||||||
const CATALOGS = { "usage.v2": require("./features.v2.json"), "usage.v3": require("./features.v3.json"), "usage.v4": CATALOG };
|
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" };
|
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.v4";
|
const CURRENT_SCHEMA_VERSION = "usage.v5";
|
||||||
const CURRENT_CONSENT_VERSION = CONSENT_VERSIONS[CURRENT_SCHEMA_VERSION];
|
const CURRENT_CONSENT_VERSION = CONSENT_VERSIONS[CURRENT_SCHEMA_VERSION];
|
||||||
const schemaForConsent = (consent) => Object.keys(CONSENT_VERSIONS).find((version) => CONSENT_VERSIONS[version] === consent);
|
const schemaForConsent = (consent) => Object.keys(CONSENT_VERSIONS).find((version) => CONSENT_VERSIONS[version] === consent);
|
||||||
const schemaRank = (version) => Object.keys(CONSENT_VERSIONS).indexOf(version);
|
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 } : {}) })
|
key, object({ configured: boolean, ...(observesUse(key, version) ? { used: boolean } : {}) })
|
||||||
]))),
|
]))),
|
||||||
gallery_layouts: { type: "array", uniqueItems: true, maxItems: LAYOUTS.length, items: { enum: LAYOUTS } },
|
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 }
|
{ type: "integer", minimum: 0, maximum: MAX_INVENTORY_COUNT }
|
||||||
]))) } : {}),
|
]))) } : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
+146
-239
@@ -1,251 +1,158 @@
|
|||||||
# Product-usage coverage: usage.v4
|
# Product-usage coverage: usage.v5
|
||||||
|
|
||||||
Reviewed PicPeak baseline: a5ff9264 (3.124.1-beta.0), plus the usage integration.
|
## What the numbers mean
|
||||||
The inventory covers 81 backend route families (80 product families plus usage),
|
|
||||||
all 26 feature flags and all current settings tabs. This is capability coverage,
|
|
||||||
not instrumentation of every UI field. Source of truth: `usage-coverage.v4.json`.
|
|
||||||
The prior v2/v3 inventories and all v1/v2/v3 wire catalogs/schemas remain unchanged.
|
|
||||||
|
|
||||||
## Data scope
|
The 86 v4 capabilities have all been reviewed for the distinction between
|
||||||
|
availability, a present configuration, and observed use. v5 asks 87 questions:
|
||||||
|
six broad admin-management questions are replaced by precisely defined edit
|
||||||
|
signals, and one question measures real template-mail transport acceptance.
|
||||||
|
There are 64 configured/used pairs and 23 configuration-only signals.
|
||||||
|
Historical views retain all 94 keys separately. No old value is renamed,
|
||||||
|
backfilled, reinterpreted, or combined with its replacement.
|
||||||
|
|
||||||
There are 86 capabilities: the original 19 in v1, 54 added in v2, and 13 added in
|
- **Built in** is availability, never an adoption percentage. This applies to
|
||||||
v3. v4 replaces `gallery_downloads` with `gallery_downloads_restricted`; the active
|
gallery/media management, export/maintenance/archive/share/short-link tools,
|
||||||
catalog remains at 86. 63 have configured/used booleans; 23 are configuration-only
|
restore/import, moderation, guest administration, XMP, replacement and marks,
|
||||||
and omit `used`. Historical views retain both questions separately (87 total keys).
|
as well as the built-in editors. An operation remains the evidence of use.
|
||||||
ML face recognition was already included: only effective availability and a
|
- **Enabled / configuration present** can be the shipped default, an inherited
|
||||||
successful authenticated admin capability operation, never biometric results.
|
value or an explicit choice. Flags and configuration-only signals do not
|
||||||
|
establish deliberate setup, successful delivery, or visitor activity.
|
||||||
|
- **Observed since consent** is one monotonic boolean. It is not frequency,
|
||||||
|
recent activity, the present configuration, or proof of a job's completion.
|
||||||
|
Existing operation signals keep their documented boundaries (including
|
||||||
|
explicit connection tests, accepted jobs and admin calendar/analytics reads).
|
||||||
|
- The six new edit signals compare accepted, persisted product values. Empty
|
||||||
|
requests, timestamps, previews, unchanged saves and automatically seeded
|
||||||
|
records do not establish editing. Restoring a different default does count
|
||||||
|
as an edit. Missing setting baselines are conservatively not inferred.
|
||||||
|
- CMS editing means a real internal title/body change. Changing only a logo or
|
||||||
|
external link does not establish internal content editing; no page-view
|
||||||
|
hooks are added. Earlier customization is not inferred from row existence,
|
||||||
|
creation/update timestamps, activity logs or current private contents.
|
||||||
|
- Template editing and template sending are independent. An unchanged shipped
|
||||||
|
template can be sent. Sending means SMTP accepted at least one recipient or
|
||||||
|
the configured mail webhook accepted the real message. Preview, test, raw
|
||||||
|
composer and newsletter messages do not set this bit. It includes queued
|
||||||
|
and background template mail, but proves neither receipt nor reading.
|
||||||
|
|
||||||
v3 and v4 report exactly two installation-wide integers under `inventory`:
|
A false edit/use bit means no qualifying observation since accepted consent;
|
||||||
current gallery records and non-video photo records. Counts include drafts and
|
it must never be presented as proof of unchanged defaults or lifetime non-use.
|
||||||
retained archive records. They are not uploads, unique files or processing-success
|
Old reporters' missing new questions are unknown, not false.
|
||||||
counts. No grouping by gallery, customer, user, content, media format or source.
|
|
||||||
No extra entity rows are fetched: inventory is computed using database counts.
|
|
||||||
Each count is a nonnegative integer at most 1,000,000,000; invalid/out-of-range
|
|
||||||
counts fail rather than being silently rounded or truncated.
|
|
||||||
|
|
||||||
No identities, business values, documents, image contents, messages, IPs, domains,
|
## Privacy and consent
|
||||||
URLs, filenames, secrets, biometric results, per-action timestamps or frequencies.
|
|
||||||
A stable reporter fingerprint and feature/count combinations remain pseudonymous,
|
|
||||||
not anonymous. Existing access controls and opt-out deletion apply to all fields.
|
|
||||||
|
|
||||||
`configured` means technical availability or configuration, not evidence of use.
|
New observations contain only fixed allowlisted booleans. The existing two
|
||||||
`used` is one monotonic bit since confirmed consent to the reporting schema. It
|
installation inventory totals (galleries and non-video photo records) are
|
||||||
means successful authenticated admin capability operation, not necessarily final
|
unchanged. No content, recipient, template name/key, category/event identifiers,
|
||||||
completion of a queued job. New operation-specific bits use trusted success
|
logo, business value, per-action count, timestamp or content hash is stored in
|
||||||
signals in their handlers; failed operations and no-op conversions do not count.
|
usage markers or transmitted. Comparisons take place locally in the existing
|
||||||
Configuration-only signals never observe visitor/customer activity. Total photo
|
admin operation; no content is scanned to reconstruct past usage.
|
||||||
records can include guest uploads without observing individual upload actions.
|
|
||||||
|
|
||||||
## Consent and version transition
|
Reports retain the existing stable installation fingerprint, PicPeak version,
|
||||||
|
daily date/generation time and gallery-layout enum. They are pseudonymous,
|
||||||
|
not fully anonymous. Existing retention, access controls and deletion apply.
|
||||||
|
|
||||||
- v1/v2/v3 retain their exact sender and receiver contracts. Updating code does
|
Every v1–v4 wire schema/catalog remains immutable. New evidence is retained only
|
||||||
not grant consent to v4 or start collecting the restricted-downloads signal.
|
under active, confirmed usage-consent.v5. The signed consent upgrade preserves
|
||||||
- v4 asks whether at least one gallery has downloads disabled, instead of the
|
identity and raw history, finishes pending old packets unchanged and resets
|
||||||
v2/v3 question whether at least one gallery allows them. These questions are
|
markers only after the matching receipt. Opt-out wins over late receipts.
|
||||||
not complements: mixed galleries can make both true. Never invert old values.
|
A marker write failure must not retry a successfully sent email.
|
||||||
- The EN/DE dialog explains the change and all 86 capabilities plus both totals.
|
Deploy the collector before the PicPeak client; existing clients keep working.
|
||||||
An unchecked checkbox requires explicit `usage-consent.v4` consent.
|
|
||||||
- Prior queued packets finish unchanged: preserve packet ID, sequence, payload,
|
|
||||||
logical day and digest. Only issue time, nonce and signature change on retry.
|
|
||||||
Never rebuild an existing packet with a new snapshot under its old packet ID.
|
|
||||||
- Signed v4 consent can upgrade v1/v2/v3 without changing identity or history.
|
|
||||||
Only the matching accepted receipt changes local scope and resets local markers.
|
|
||||||
Lost receipts remain retryable; opt-out always wins over a late response.
|
|
||||||
- Collector first, client second. v1/v2/v3 remain accepted even after v4 consent.
|
|
||||||
Missing fields stay unknown. Historical aggregation uses the union of known
|
|
||||||
questions, while the active v4 sender still has exactly 86 fields.
|
|
||||||
|
|
||||||
## Every reported capability
|
## Full audit
|
||||||
|
|
||||||
Definitions are shipped byte-identically in both applications as
|
The table is the decision for every active v5 capability. The route/flag/settings
|
||||||
`features.v4.json`, served at `/schema/features.v4.json`. The catalog is English only; the German labels live in the frontend locale file (`productUsage.catalog`).
|
inventory is `usage-coverage.v5.json`; historical inventories remain unchanged.
|
||||||
|
Exact definitions are served at `/schema/features.v5.json` and disclosed in the
|
||||||
|
PicPeak EN/DE consent catalog. A retired broad management signal remains under
|
||||||
|
its original definition in the collector's earlier-measurements view/history.
|
||||||
|
|
||||||
| Key (EN) | Since | Configured | Used |
|
| Capability | Availability/configuration | Evidence of use |
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `crm` — Client management | usage.v1 | The clients capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `crm_quotes` — Quotes | usage.v1 | The quotes capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `crm_invoices` — Invoices | usage.v1 | The bills capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `crm_contracts` — Contracts | usage.v1 | The contracts capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `crm_projects` — Projects | usage.v1 | The projects capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `crm_calendar` — Admin calendar | usage.v1 | The calendar capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `crm_hours` — Hours logging | usage.v1 | The hoursLogging capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `customer_portal` — Customer portal | usage.v1 | The customerPortal capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `accounting` — Accounting | usage.v1 | The accounting capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `workflows` — Workflows | usage.v1 | The workflows capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `newsletters` — Newsletters | usage.v1 | The newsletters capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `face_recognition` — ML face recognition | usage.v1 | The faces capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `custom_css` — Custom CSS | usage.v1 | Custom CSS is configured globally or applied through a gallery/theme/template; CSS text is not sent. | Applied CSS observed after consent, without observing visitors. |
|
|
||||||
| `oauth` — Admin SSO | usage.v1 | Admin OIDC is enabled and issuer/client configuration is present; no provider or credential values. | Successful admin SSO login; no account, identity-provider or session details. |
|
|
||||||
| `smtp` — SMTP delivery | usage.v1 | An outgoing SMTP host is configured; no host, account, address or credentials. | A successful explicitly initiated admin SMTP test/send; no recipients or messages. |
|
|
||||||
| `whatsapp` — WhatsApp integration | usage.v1 | The WhatsApp capability is enabled and a usable configuration is present; no phone number, token or template. | Successful admin integration test; no recipient, message or delivery history. |
|
|
||||||
| `backup` — Backups | usage.v1 | A full or database backup schedule is enabled; no schedule, path, storage sizes or backup names. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `s3_storage` — S3 storage / S3-Speicher | usage.v1 | S3 is configured for media or backups; no bucket, endpoint, credentials or object keys. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `share_mounts` — External folders | usage.v1 | At least one gallery uses an external folder; only existence, no folder paths or gallery identifiers. | An admin initiated an accepted external-folder import; no scanned paths, files or counts. |
|
|
||||||
| `galleries` — Gallery management | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `photo_management` — Media management | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `photo_exports` — Admin media export | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `photo_processing` — Media maintenance tools | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `archive_management` — Gallery archives | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `gallery_sharing` — Gallery sharing and QR | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `short_links` — Short links | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `gallery_categories` — Photo categories | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `event_types` — Event types and presets | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `slideshow` — Live slideshow | usage.v2 | The slideshow capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `transfers` — PicTransfer | usage.v2 | The transfers capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `video_uploads` — Admin video uploads | usage.v2 | Video extensions are allowed in global upload settings; no uploaded-file metadata. | At least one admin video file was successfully stored/accepted; no names, formats, lengths, sizes or processing/visitor history. |
|
|
||||||
| `camera_raw_uploads` — Admin camera RAW uploads | usage.v2 | Camera RAW (DNG) is allowed in global upload settings; no camera models or EXIF. | At least one admin camera RAW upload was stored/accepted; only the capability bit, no filename or metadata. |
|
|
||||||
| `messaging` — Messaging tools | usage.v2 | The messaging capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `incoming_mail` — IMAP intake | usage.v2 | Incoming mail is enabled and an IMAP configuration is present; no mailbox, server, folders or credentials. | A successful explicit admin connection test or non-skipped manual poll; no background intake, messages, attachments or counts. |
|
|
||||||
| `reminder_emails` — Automatic event reminders | usage.v2 | The reminderEmails capability switch is effectively enabled; only a boolean. | Not collected: configuration only. |
|
|
||||||
| `email_templates` — Email templates | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `email_webhook` — Email webhook transport | usage.v2 | Both email webhook settings are present; no URL or secret. | Successful explicitly initiated admin send/test through the webhook transport; no recipients, messages or automatic deliveries. |
|
|
||||||
| `accounting_incoming_invoices` — Incoming invoices | usage.v2 | The incomingInvoices capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `accounting_expenses` — Expenses | usage.v2 | The expenses capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `accounting_tax_report` — Tax reports | usage.v2 | The taxReport capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `accounting_ledger` — Ledger and accounting export | usage.v2 | The accounting capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `crm_installments` — Installment-plan tools | usage.v2 | Quotes or invoices are enabled; no actual payment plans, amounts or statuses are inspected. | An admin saved an installment plan; no dates, amounts, currencies, payment status or document IDs. |
|
|
||||||
| `document_templates` — Document presets and blocks | usage.v2 | Quotes or contracts are enabled, making document presets/blocks available; no template content. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `cms` — CMS pages | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `public_site` — Public landing page | usage.v2 | The public landing-page setting is enabled; no page HTML, texts, domains or visitors. | Not collected: configuration only. |
|
|
||||||
| `branding` — Branding settings | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `seo_customization` — SEO settings | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `admin_management` — Admin and role management | usage.v2 | The userManagement capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `api_integration` — HTTP API integration | usage.v2 | An unrevoked, unexpired API credential exists; no tokens, names, scopes or owner data. | Successful authenticated HTTP API capability call; only this bit, never URLs, request values, token/owner IDs or call counts. Does not trigger a report. |
|
|
||||||
| `webhooks` — Outbound webhooks | usage.v2 | At least one active webhook is configured; no destinations, subscriptions, secrets or delivery logs. | Successful explicit admin webhook test/replay; no automatic or visitor-triggered deliveries. |
|
|
||||||
| `restore` — Restore | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `portable_backup` — Portable PicPeak export/import | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `database_backup` — Database backups | usage.v2 | Scheduled database backups are enabled; no schedules, file names or database contents. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `s3_photo_storage` — S3 media storage / S3-Medienspeicher | usage.v2 | S3 is the configured media backend and required credentials are present; no values are sent. | Successful admin media storage/accepted upload to S3; no buckets, objects or sizes. |
|
|
||||||
| `s3_backups` — S3 backup destination / S3-Sicherungsziel | usage.v2 | The configured backup destination is S3 with a bucket present; no bucket or credentials. | An admin started a backup to the configured S3 destination or a successful S3 test upload; local exports never imply S3 use. |
|
|
||||||
| `analytics_dashboard` — Existing analytics module | usage.v2 | The analytics capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `feedback_moderation` — Feedback moderation | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `guest_management` — Guest administration tools | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
|
||||||
| `gallery_feedback_likes` — Gallery likes enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_feedback_ratings` — Gallery star ratings enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_feedback_comments` — Gallery comments enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_feedback_favorites` — Gallery favorites enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_feedback_reactions` — Gallery reactions enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_feedback_color_labels` — Gallery color labels enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_guest_accounts` — Guest identities enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_guest_uploads` — Guest uploads enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_downloads_restricted` — Gallery downloads restricted | usage.v4 | At least one gallery has downloads switched off; only existence across the installation, never gallery IDs or counts. | Not collected (configuration only). |
|
|
||||||
| `download_resolution_picker` — Download resolution picker enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_client_access` — Client access enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_watermarks` — Watermarks enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_image_protection` — Image protection enabled | usage.v2 | Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_reveal` — Gallery reveal enabled | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `gallery_expiration` — Gallery expiration configured | usage.v2 | At least one gallery has an expiry configured; no dates, gallery IDs or counts. | Not collected: configuration only. |
|
|
||||||
| `photo_xmp_export` — XMP export | usage.v3 | Built-in capability is available; this is not evidence of use. | An admin successfully generated an XMP export; no sidecars, filenames, ratings, selections or counts. |
|
|
||||||
| `photo_replacement` — Photo replacement | usage.v3 | Built-in capability is available; this is not evidence of use. | An admin upload actually replaced a photo successfully; no filenames, matching values, IDs or counts. |
|
|
||||||
| `photo_admin_marks` — Photographer marks | usage.v3 | Built-in capability is available; this is not evidence of use. | An admin successfully saved their own photo mark; no rating, color, photo or admin identity. |
|
|
||||||
| `gallery_folders` — Gallery folders configured | usage.v3 | An applicable global or gallery category is configured as a folder; no names, contents, counts or visitor activity. | Not collected: configuration only. |
|
|
||||||
| `transfer_upload_links` — PicTransfer upload links enabled | usage.v3 | PicTransfer is enabled and a non-deleted transfer allows unexpired uploads; no links, tokens, dates, recipients or uploads. | Not collected: configuration only. |
|
|
||||||
| `workflow_automation_enabled` — Workflow automation enabled | usage.v3 | The workflows module and at least one workflow are enabled; no names, graphs, triggers, decisions or runs. | Not collected: configuration only. |
|
|
||||||
| `s3_auto_import` — S3 automatic import enabled / Automatischer S3-Import aktiviert | usage.v3 | S3 media storage is configured and STORAGE_AUTO_IMPORT is enabled; no bucket, prefix, credentials, polling or imported objects. | Not collected: configuration only. |
|
|
||||||
| `crm_invoice_import` — Invoice import | usage.v3 | The required product capabilities are effectively enabled; only a boolean. | An admin successfully imported an existing invoice; no PDF, invoice number, amount, currency, customer or payment status. |
|
|
||||||
| `crm_combined_billing` — Combined billing | usage.v3 | The required product capabilities are effectively enabled; only a boolean. | An admin successfully created a combined bill; no hours, expenses, customer, documents or financial values. |
|
|
||||||
| `crm_monthly_billing_manual` — Manual monthly billing | usage.v3 | The required product capabilities are effectively enabled; only a boolean. | An admin successfully released a monthly draft for delivery; actual email delivery is not measured. No scheduler activity, customer, cadence or invoice values. |
|
|
||||||
| `crm_document_conversion` — Document conversion | usage.v3 | The required product capabilities are effectively enabled; only a boolean. | An admin successfully converted a quote or contract into a document or gallery; no content, links, acceptance states or automatic workflows. |
|
|
||||||
| `gallery_capture_date_sort` — Capture-date sorting configured | usage.v3 | A gallery defaults to sorting by capture date; no capture dates, EXIF or visitor sorting actions. | Not collected: configuration only. |
|
|
||||||
| `download_original_filenames` — Original download filenames enabled | usage.v3 | The original-download-filenames switch is enabled; no filenames or downloads are read or sent. | Not collected: configuration only. |
|
|
||||||
|
|
||||||
## Inventory totals
|
|
||||||
|
|
||||||
- `inventory.galleries` — Current number of gallery records, including drafts, inactive and archived galleries. Deleted galleries are excluded. One total for the installation, no breakdown or identifiers.
|
|
||||||
- `inventory.photos` — Current number of non-video photo records, including RAW, guest uploads and records of archived galleries. One total for the installation; not unique files, thumbnails, processing success or photo contents. Deleted records are excluded.
|
|
||||||
|
|
||||||
## Route-family decisions
|
|
||||||
|
|
||||||
| Family | Coverage | Boundary |
|
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `acceptInvite.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `crm` — Client management | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `admin.js` | composition: no telemetry | Router composition / helpers; decisions are recorded for each mounted family. |
|
| `crm_quotes` — Quotes | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminApiTokens.js` | configuration: `api_integration` | Only existence of a valid credential; no marker from token listing/creation, no scope, owner, token, expiry date or last-used time. |
|
| `crm_invoices` — Invoices | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminArchives.js` | partial: `galleries`, `archive_management`, `photo_exports` | Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded. |
|
| `crm_contracts` — Contracts | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminAuth.js` | excluded: no telemetry | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
| `crm_projects` — Projects | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminBackup.js` | partial: `backup`, `portable_backup`, `restore`, `s3_storage`, `s3_backups` | Admin backup initiation, portable export/import and successful S3 roundtrip test. Local export never implies S3; names, schedules, sizes, contents and history excluded. |
|
| `crm_calendar` — Admin calendar | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminBusinessProfile.js` | excluded: no telemetry | Business identity/bank/tax-address configuration and VAT-code helper surface are not separate usage signals. Billing/accounting capabilities are covered without profiling the business. |
|
| `crm_hours` — Hours logging | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminCalendar.js` | partial: `crm`, `crm_calendar` | Authenticated admin calendar retrieval is capability use; no calendar entries, dates, recurrence, availability or bookings. |
|
| `customer_portal` — Customer portal | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminCategories.js` | partial: `gallery_categories`, `gallery_folders` | Admin category CRUD; no names, descriptions, colors or ordering values. v3 adds only: gallery_folders. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `accounting` — Accounting | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminCMS.js` | partial: `cms` | Admin CMS page CRUD only. Public page traffic, slug, HTML, text, links and media excluded. |
|
| `workflows` — Workflows | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminContracts.js` | partial: `crm`, `crm_contracts`, `document_templates`, `crm_document_conversion` | Admin contract/block operations only; no legal text, signatures, signing parties or customer signing events. v3 adds only: crm_document_conversion. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `newsletters` — Newsletters | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminCssTemplates.js` | configuration: `custom_css` | Only existence of enabled applied CSS and locally observed application, not editing/viewing templates or any CSS text. |
|
| `face_recognition` — ML face recognition | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminCustomers.js` | partial: `crm`, `crm_hours`, `customer_portal`, `crm_combined_billing`, `crm_monthly_billing_manual` | Successful admin CRM/hour-entry/invitation operations only. No customer/account names, IDs, rates, billed hours, payment state or portal behavior. v3 adds only: crm_combined_billing, crm_monthly_billing_manual. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `custom_css` — Custom CSS | Technical configuration exists; may be a default, not activity. | Applied CSS observed after consent, without observing visitors. |
|
||||||
| `adminDashboard.js` | partial: `analytics_dashboard` | Admin analytics capability endpoint only; no stats, activities, health/CRM polls, underlying visitor data or dashboard values. |
|
| `oauth` — Admin SSO | Technical configuration exists; may be a default, not activity. | Successful admin SSO login; no account, identity-provider or session details. |
|
||||||
| `adminDatabaseBackup.js` | partial: `backup`, `database_backup` | Admin database-backup initiation plus schedule-enabled boolean, no file data/history. |
|
| `smtp` — SMTP delivery | Technical configuration exists; may be a default, not activity. | A successful explicitly initiated admin SMTP test/send; no recipients or messages. |
|
||||||
| `adminDeals.js` | partial: `crm`, `crm_installments` | Admin installment-plan changes only. No actual plans, invoice links, amounts, paid states or deal reporting. |
|
| `whatsapp` — WhatsApp integration | Technical configuration exists; may be a default, not activity. | Successful admin integration test; no recipient, message or delivery history. |
|
||||||
| `adminDev.js` | excluded: no telemetry | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
| `backup` — Backups | Technical configuration exists; may be a default, not activity. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminEmail.js` | partial: `messaging`, `incoming_mail`, `smtp`, `email_templates`, `email_webhook`, `reminder_emails` | Admin message operation/template edit, actual successful manual send/test transport and non-skipped manual IMAP poll/test. Reminder flag configuration only. No automated sends/polls, received-message or recipient data, queue/log reads, mailbox addresses or templates. |
|
| `s3_storage` — S3 storage | Technical configuration exists; may be a default, not activity. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminEventRename.js` | partial: `galleries` | Successful rename only, not validate-rename. No former/new names or identifiers. |
|
| `share_mounts` — External folders | Technical configuration exists; may be a default, not activity. | An admin initiated an accepted external-folder import; no scanned paths, files or counts. |
|
||||||
| `adminEvents/archiveBulk.js` | partial: `galleries`, `archive_management`, `photo_exports` | Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded. |
|
| `galleries` — Gallery management | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminEvents/crud.js` | partial: `galleries`, `gallery_guest_uploads`, `gallery_downloads_restricted`, `gallery_client_access`, `gallery_watermarks`, `gallery_reveal`, `gallery_expiration`, `gallery_sharing`, `custom_css`, `gallery_capture_date_sort` | Admin creation/edit/publish etc. set galleries; sharing has its own fixed key. Guest/download/protection/reveal/expiry are configuration only; themes contribute controlled layouts and CSS presence. No gallery metadata or guest action history. v3 adds only: gallery_capture_date_sort. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `photo_management` — Media management | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminEvents/downloadResolutions.js` | configuration: `download_resolution_picker` | Only whether a picker is configured globally or in a gallery. No chosen resolution, download event or counts. |
|
| `photo_exports` — Admin media export | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminEvents/faces.js` | partial: `face_recognition` | Effective flag plus successful admin faces/people operation. No health polling, embeddings, names, groups, detections or visitor searches. |
|
| `photo_processing` — Media maintenance tools | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminEvents/helpers.js` | composition: no telemetry | Router composition / helpers; decisions are recorded for each mounted family. |
|
| `archive_management` — Gallery archives | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminEvents/index.js` | composition: no telemetry | Router composition / helpers; decisions are recorded for each mounted family. |
|
| `gallery_sharing` — Gallery sharing and QR | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminEvents/logo.js` | partial: `branding` | Successful admin logo operation only; image/filename/content excluded. |
|
| `short_links` — Short links | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminEvents/qr.js` | partial: `gallery_sharing` | Admin QR generation only; no scans, tokens or URLs. |
|
| `category_editing` — Category customization | Built in; display a label, not a percentage. | An admin created, changed or deleted a category since consent. Seeded categories, reading and unchanged saves do not count. No names, memberships or identifiers are retained. |
|
||||||
| `adminEvents/resets.js` | partial: `galleries`, `gallery_sharing` | Admin gallery reset/sharing capability only; no password, recipient, token or reset statistics. |
|
| `event_type_editing` — Event type customization | Built in; display a label, not a percentage. | An admin created, changed or deleted an event type since consent. Seeded presets, reading and unchanged saves do not count. No names, presets or identifiers are retained. |
|
||||||
| `adminEvents/slideshow.js` | partial: `slideshow` | Admin generate/disable/configure only, never kiosk viewers or slide advances. |
|
| `slideshow` — Live slideshow | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminEventTypes.js` | partial: `event_types` | Admin event-type CRUD; preset contents/names excluded. |
|
| `transfers` — PicTransfer | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminExpenses.js` | partial: `accounting`, `accounting_expenses`, `accounting_incoming_invoices` | Admin expense/inbound-invoice operations; no financial values, suppliers, mileage/location, dates, receipt files or OCR text. |
|
| `video_uploads` — Admin video uploads | Technical configuration exists; may be a default, not activity. | At least one admin video file was successfully stored/accepted; no names, formats, lengths, sizes or processing/visitor history. |
|
||||||
| `adminExternalMedia.js` | partial: `share_mounts` | Only admin import operation; status/list/browse are not use. Snapshot checks external-path presence, never reports a path. |
|
| `camera_raw_uploads` — Admin camera RAW uploads | Technical configuration exists; may be a default, not activity. | At least one admin camera RAW upload was stored/accepted; only the capability bit, no filename or metadata. |
|
||||||
| `adminFeatureFlags.js` | configuration: `crm`, `crm_quotes`, `crm_invoices`, `crm_contracts`, `crm_projects`, `crm_calendar`, `crm_hours`, `customer_portal`, `accounting`, `workflows`, `newsletters`, `face_recognition`, `slideshow`, `transfers`, `messaging`, `reminder_emails`, `accounting_incoming_invoices`, `accounting_expenses`, `accounting_tax_report`, `accounting_ledger`, `admin_management`, `analytics_dashboard` | Only allowlisted effective capability booleans. No marker from reading or saving feature flags. Disabled roadmap/developer flags excluded. |
|
| `messaging` — Messaging tools | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminFeedback.js` | partial: `feedback_moderation`, `gallery_feedback_likes`, `gallery_feedback_ratings`, `gallery_feedback_comments`, `gallery_feedback_favorites`, `gallery_feedback_reactions`, `gallery_feedback_color_labels`, `gallery_guest_accounts` | Admin moderation/word-filter operations only. Visitor feedback is not observed. Master-enabled per-gallery feedback-option booleans only; no contents, ratings, likes, colors, identities or word lists. |
|
| `incoming_mail` — IMAP intake | Technical configuration exists; may be a default, not activity. | A successful explicit admin connection test or non-skipped manual poll; no background intake, messages, attachments or counts. |
|
||||||
| `adminGuests.js` | partial: `guest_management` | Admin guest management/export initiation only. No guest names, invitations, tokens, contact data, guest counts or visitor interactions. |
|
| `reminder_emails` — Automatic event reminders | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `adminImageSecurity.js` | configuration: `gallery_image_protection` | Only gallery/global technical protection configuration existence. No security events, blocked IPs, request counts, threat scores or admin monitoring access. |
|
| `email_template_editing` — Email template customization | Built in; display a label, not a percentage. | An admin created a nonempty template or saved a real subject/body change since consent. Defaults, unchanged saves, previews and sending are excluded. This does not establish the current customization of templates edited before consent. |
|
||||||
| `adminInvoices.js` | partial: `crm`, `crm_invoices`, `crm_invoice_import` | Admin invoice operations only; no amounts, VAT/customer/payment values or payment-check responses. v3 adds only: crm_invoice_import. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `email_webhook` — Email webhook transport | Technical configuration exists; may be a default, not activity. | Successful explicitly initiated admin send/test through the webhook transport; no recipients, messages or automatic deliveries. |
|
||||||
| `adminLedger.js` | partial: `accounting`, `accounting_ledger` | Admin ledger-account/VAT/mapping edits and ledger export initiation only; no account/currency/VAT identifiers or exported records. |
|
| `accounting_incoming_invoices` — Incoming invoices | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminNewsletters.js` | partial: `newsletters` | Admin campaign changes/test/queue/cancel only. Recipient resolution, previews, subscriptions/unsubscribes, delivery/open/click data and automatic sending excluded. |
|
| `accounting_expenses` — Expenses | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminNotifications.js` | excluded: no telemetry | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
| `accounting_tax_report` — Tax reports | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminPhotoDimensions.js` | partial: `photo_processing` | Admin repair/regenerate/configuration initiation, never status polling or processing totals. |
|
| `accounting_ledger` — Ledger and accounting export | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminPhotoExport.js` | partial: `photo_exports`, `photo_xmp_export` | Admin export initiation only; export filters, selected files, sizes and contents excluded. v3 adds only: photo_xmp_export. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `crm_installments` — Installment-plan tools | Technical configuration exists; may be a default, not activity. | An admin saved an installment plan; no dates, amounts, currencies, payment status or document IDs. |
|
||||||
| `adminPhotos.js` | partial: `photo_management`, `photo_exports`, `photo_processing`, `video_uploads`, `camera_raw_uploads`, `s3_storage`, `s3_photo_storage`, `photo_replacement`, `photo_admin_marks` | Successful admin edits/exports and accepted upload evidence only. Chunk init/status, failed uploads and public downloads excluded. Only video/RAW/S3 booleans survive, never file metadata/EXIF/content. v3 adds only: photo_replacement, photo_admin_marks. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `document_templates` — Document presets and blocks | Technical configuration exists; may be a default, not activity. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminProjects.js` | partial: `crm`, `crm_projects` | Admin project operations only; project/person names, business performance, metadata and totals excluded. |
|
| `cms_content_editing` — CMS content editing | Built in; display a label, not a percentage. | An admin saved a real change to an internal CMS page title or body since consent. Unchanged saves, external links, logos, seeded pages and page views do not count. This does not measure whether anyone read the page. |
|
||||||
| `adminQuotes.js` | partial: `crm`, `crm_quotes`, `document_templates`, `crm_document_conversion` | Admin quote/preset operations only; no quote content, prices, customer acceptance or signatures. v3 adds only: crm_document_conversion. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `public_site` — Public landing page | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `adminRestore.js` | partial: `restore` | Admin restore initiation only, never file selection, content, progress, errors or timing. |
|
| `branding_editing` — Branding customization | Built in; display a label, not a percentage. | An admin changed branding settings, a theme or a logo since consent. Reading settings and unchanged saves do not count. A change can also restore a default; this is not a claim about the current design. |
|
||||||
| `adminRoles.js` | partial: `admin_management` | Admin account/role management capability; no names, permissions, role labels, password reset operations or active-user counts. Auth/self-profile endpoints excluded. |
|
| `seo_editing` — SEO customization | Built in; display a label, not a percentage. | An admin saved a real change to an allowlisted SEO setting since consent. Defaults, reading and unchanged saves do not count; no rules, paths or search-engine activity are collected. |
|
||||||
| `adminSettings.js` | partial: `custom_css`, `oauth`, `smtp`, `backup`, `s3_storage`, `video_uploads`, `camera_raw_uploads`, `public_site`, `branding`, `seo_customization`, `slideshow`, `download_resolution_picker`, `gallery_watermarks`, `database_backup`, `s3_auto_import`, `download_original_filenames` | Only specified configuration presence/booleans and explicit branding/SEO/slideshow operations. Generic settings reads, security policies, passwords, storage data, SMTP/OIDC credentials, custom HTML/CSS/SEO values excluded. v3 adds only: s3_auto_import, download_original_filenames. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `admin_management` — Admin and role management | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminShortUrls.js` | partial: `gallery_sharing`, `short_links` | Admin short-link creation/deletion only; link/token/click metadata excluded. |
|
| `api_integration` — HTTP API integration | Technical configuration exists; may be a default, not activity. | Successful authenticated HTTP API capability call; only this bit, never URLs, request values, token/owner IDs or call counts. Does not trigger a report. |
|
||||||
| `adminSystem.js` | excluded: no telemetry | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
| `webhooks` — Outbound webhooks | Technical configuration exists; may be a default, not activity. | Successful explicit admin webhook test/replay; no automatic or visitor-triggered deliveries. |
|
||||||
| `adminSystemHealth.js` | excluded: no telemetry | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
| `restore` — Restore | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminTaxReport.js` | partial: `accounting`, `accounting_tax_report` | Admin tax report generation/export only; no totals, dates, tax regimes, geography or currency. |
|
| `portable_backup` — Portable PicPeak export/import | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminThumbnails.js` | partial: `photo_processing` | Admin repair/regenerate/configuration initiation, never status polling or processing totals. |
|
| `database_backup` — Database backups | Technical configuration exists; may be a default, not activity. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminTransfers.js` | partial: `transfers`, `transfer_upload_links` | Admin transfer CRUD/files/link management/download only. Public recipients, received-file data, upload and download statistics excluded. v3 adds only: transfer_upload_links. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `s3_photo_storage` — S3 media storage | Technical configuration exists; may be a default, not activity. | Successful admin media storage/accepted upload to S3; no buckets, objects or sizes. |
|
||||||
| `adminUsage.js` | excluded: no telemetry | Consent, inspection, export, feedback, voting, deletion and abandoning an unsignable deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report. |
|
| `s3_backups` — S3 backup destination | Technical configuration exists; may be a default, not activity. | An admin started a backup to the configured S3 destination or a successful S3 test upload; local exports never imply S3 use. |
|
||||||
| `adminUsers.js` | partial: `admin_management` | Admin account/role management capability; no names, permissions, role labels, password reset operations or active-user counts. Auth/self-profile endpoints excluded. |
|
| `analytics_dashboard` — Existing analytics module | Enabled switch/capability; may be a default. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminVatCodes.js` | excluded: no telemetry | Business identity/bank/tax-address configuration and VAT-code helper surface are not separate usage signals. Billing/accounting capabilities are covered without profiling the business. |
|
| `feedback_moderation` — Feedback moderation | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminWebhooks.js` | partial: `webhooks` | Active configuration existence plus successful admin manual test/replay enqueue. Actual network delivery/results/subscriptions/destinations excluded. |
|
| `guest_management` — Guest administration tools | Built in; display a label, not a percentage. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
| `adminWhatsapp.js` | partial: `whatsapp` | Effective configured sender and successful manual test only. No automated deliveries, phone numbers, templates or delivery statuses. |
|
| `gallery_feedback_likes` — Gallery likes enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `adminWorkflows.js` | partial: `workflows`, `workflow_automation_enabled` | Admin workflow authoring/approval/test initiation only. Runtime triggers, payloads, execution frequency/results and public approvals excluded. v3 adds only: workflow_automation_enabled. Exact definitions are in features.v4.json; configuration-only signals never observe the public surface. |
|
| `gallery_feedback_ratings` — Gallery star ratings enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `analyticsTrackerProxy.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_feedback_comments` — Gallery comments enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `auth.js` | partial: `oauth` | Only successful admin OIDC callback sets oauth. Password/gallery authentication, MFA, account claims and provider details excluded. |
|
| `gallery_feedback_favorites` — Gallery favorites enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `customer.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_feedback_reactions` — Gallery reactions enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `customerAuth.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_feedback_color_labels` — Gallery color labels enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `gallery.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_guest_accounts` — Guest identities enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `galleryFeedback.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_guest_uploads` — Guest uploads enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `galleryGuests.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_downloads_restricted` — Gallery downloads restricted | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `protectedImages.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `download_resolution_picker` — Download resolution picker enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `publicCMS.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_client_access` — Client access enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `publicContracts.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_watermarks` — Watermarks enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `publicFonts.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_image_protection` — Image protection enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `publicNewsletter.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_reveal` — Gallery reveal enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `publicPaymentCheck.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_expiration` — Gallery expiration configured | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `publicQuotes.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `photo_xmp_export` — XMP export | Built in; display a label, not a percentage. | An admin successfully generated an XMP export; no sidecars, filenames, ratings, selections or counts. |
|
||||||
| `publicSettings.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `photo_replacement` — Photo replacement | Built in; display a label, not a percentage. | An admin upload actually replaced a photo successfully; no filenames, matching values, IDs or counts. |
|
||||||
| `publicTransfer.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `photo_admin_marks` — Photographer marks | Built in; display a label, not a percentage. | An admin successfully saved their own photo mark; no rating, color, photo or admin identity. |
|
||||||
| `publicTransferUpload.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `gallery_folders` — Gallery folders configured | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `publicWorkflowApprovals.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `transfer_upload_links` — PicTransfer upload links enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `secureImages.js` | excluded: no telemetry | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
| `workflow_automation_enabled` — Workflow automation enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `setup.js` | excluded: no telemetry | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
| `s3_auto_import` — S3 automatic import enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
| `v1/events.js` | partial: `api_integration` | Single bit after successful admin-owned scoped API authentication. No request/response values; API requests do not trigger reports. |
|
| `crm_invoice_import` — Invoice import | Enabled switch/capability; may be a default. | An admin successfully imported an existing invoice; no PDF, invoice number, amount, currency, customer or payment status. |
|
||||||
|
| `crm_combined_billing` — Combined billing | Enabled switch/capability; may be a default. | An admin successfully created a combined bill; no hours, expenses, customer, documents or financial values. |
|
||||||
## Excluded runtime
|
| `crm_monthly_billing_manual` — Manual monthly billing | Enabled switch/capability; may be a default. | An admin successfully released a monthly draft for delivery; actual email delivery is not measured. No scheduler activity, customer, cadence or invoice values. |
|
||||||
|
| `crm_document_conversion` — Document conversion | Enabled switch/capability; may be a default. | An admin successfully converted a quote or contract into a document or gallery; no content, links, acceptance states or automatic workflows. |
|
||||||
- Gallery/customer/public events and optional website analytics
|
| `gallery_capture_date_sort` — Capture-date sorting configured | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
- Automated newsletter, reminder, WhatsApp, webhook and IMAP jobs
|
| `download_original_filenames` — Original download filenames enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
|
||||||
- Security/audit logs, biometric embeddings and recognition results
|
| `email_template_delivery` — Emails sent using templates | Built in; display a label, not a percentage. | At least one real template email was accepted by SMTP or the configured mail webhook since consent, including background sends. Previews, test messages and template-free messages are excluded. Acceptance does not prove receipt or reading. No template key, recipient, contents, message identifier, send time or count is stored in usage markers. |
|
||||||
- Operational health, migration, update and polling metrics
|
|
||||||
- Business/customer/user identities, geography, financial amounts and document contents; only explicit v3/v4 inventory totals are permitted.
|
|
||||||
- Disabled calendarBooking and internal crmDevelopment; hosted future product #1111
|
|
||||||
- Image fragmentation: removed from current PicPeak, not a live capability
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import catalog from './usageFeatures.v4.json';
|
import catalog from './usageFeatures.v5.json';
|
||||||
|
|
||||||
/** Local, static disclosure: opening it never contacts the collector. */
|
/** Local, static disclosure: opening it never contacts the collector. */
|
||||||
export function UsageCatalog() {
|
export function UsageCatalog() {
|
||||||
@@ -29,7 +29,7 @@ export function UsageCatalog() {
|
|||||||
<section key={key} className="border-t border-theme pt-2">
|
<section key={key} className="border-t border-theme pt-2">
|
||||||
<h4 className="font-semibold">{t(`productUsage.catalog.${key}.name`)}</h4>
|
<h4 className="font-semibold">{t(`productUsage.catalog.${key}.name`)}</h4>
|
||||||
<p className="text-xs"><code>{key}</code> · {definition.since}</p>
|
<p className="text-xs"><code>{key}</code> · {definition.since}</p>
|
||||||
<p className="text-sm">{t('productUsage.configuredLabel')}: {t(`productUsage.catalog.${key}.configured`)}</p>
|
<p className="text-sm">{t(definition.configuration === 'builtin' ? 'productUsage.builtinLabel' : definition.configuration === 'flag' || definition.configuration === 'capability' ? 'productUsage.enabledLabel' : 'productUsage.configuredLabel')}: {t(`productUsage.catalog.${key}.configured`)}</p>
|
||||||
<p className="text-sm">{definition.used
|
<p className="text-sm">{definition.used
|
||||||
? `${t('productUsage.usedLabel')}: ${t(`productUsage.catalog.${key}.used`)}`
|
? `${t('productUsage.usedLabel')}: ${t(`productUsage.catalog.${key}.used`)}`
|
||||||
: t('productUsage.configurationOnly')}</p>
|
: t('productUsage.configurationOnly')}</p>
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ beforeEach(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
it('shows every v4 signal locally before participation, without collector calls', async () => {
|
it('shows every v5 signal locally before participation, without collector calls', async () => {
|
||||||
mount();
|
mount();
|
||||||
await screen.findByText('productUsage.catalogTitle');
|
await screen.findByText('productUsage.catalogTitle');
|
||||||
expect(screen.getAllByRole('heading', { level: 4, hidden: true })).toHaveLength(87);
|
expect(screen.getAllByRole('heading', { level: 4, hidden: true })).toHaveLength(88);
|
||||||
expect(service.enable).not.toHaveBeenCalled();
|
expect(service.enable).not.toHaveBeenCalled();
|
||||||
expect(service.preview).not.toHaveBeenCalled();
|
expect(service.preview).not.toHaveBeenCalled();
|
||||||
expect(service.upgradeConsent).not.toHaveBeenCalled();
|
expect(service.upgradeConsent).not.toHaveBeenCalled();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"productUsage": {
|
"productUsage": {
|
||||||
"fields": "usage.v4-Berichte enthalten einen Installationsfingerabdruck, die PicPeak-Version, UTC-Berichtsdatum und Erstellungszeit, Schema- und Signaturmetadaten, die Galerie-Layouts aus einer festen Liste, 86 Funktionssignale (63 Paare aus Konfiguriert und Genutzt, 23 reine Konfigurationswerte) sowie zwei Gesamtzahlen der Installation: gespeicherte Galerien und Fotoeinträge ohne Videos. Entwürfe, archivierte Galerien und deren erhaltene Fotoeinträge zählen mit. Der Katalog unten erklärt jedes Feld. Keine Aktionszähler, keine Beobachtung von Besuchern.",
|
"fields": "usage.v5-Berichte enthalten einen Installationsfingerabdruck, die PicPeak-Version, UTC-Berichtsdatum und Erstellungszeit, Schema- und Signaturmetadaten, die Galerie-Layouts aus einer festen Liste, 87 Funktionssignale (64 Paare aus Konfiguriert und Genutzt, 23 reine Konfigurationswerte) sowie zwei Gesamtzahlen der Installation: gespeicherte Galerien und Fotoeinträge ohne Videos. Entwürfe, archivierte Galerien und deren erhaltene Fotoeinträge zählen mit. Der Katalog unten erklärt jedes Feld. Keine Aktionszähler, keine Beobachtung von Besuchern.",
|
||||||
"catalogTitle": "Vollständiger Katalog: 86 Funktionssignale und 2 Bestandszahlen (usage.v4)",
|
"catalogTitle": "Vollständiger Katalog: 87 Funktionssignale und 2 Bestandszahlen (usage.v5)",
|
||||||
"catalogExplanation": "„Konfiguriert“ beschreibt, ob eine Funktion technisch verfügbar oder eingerichtet ist. „Integriert“ heißt: immer verfügbar, ohne Nutzungsnachweis. „Genutzt“ ist ein einzelnes Ja/Nein für die ganze Installation seit der Zustimmung zu diesem Schema; ein angenommener Auftrag zählt als gestartet, nicht als abgeschlossen. Reine Konfigurationssignale haben kein Genutzt-Feld. Der Bestand enthält nur die aktuellen Gesamtzahlen der Galerie- und Fotoeinträge, ohne Aufschlüsselung nach Galerien. Nutzungsmarker speichern keine Personen, Objektkennungen, Zeitpunkte oder Häufigkeiten.",
|
"catalogExplanation": "„Konfiguriert“ beschreibt, ob eine Funktion technisch verfügbar oder eingerichtet ist. „Integriert“ heißt: immer verfügbar, ohne Nutzungsnachweis. „Genutzt“ ist ein einzelnes Ja/Nein für die ganze Installation seit der Zustimmung zu diesem Schema; ein angenommener Auftrag zählt als gestartet, nicht als abgeschlossen. Reine Konfigurationssignale haben kein Genutzt-Feld. Der Bestand enthält nur die aktuellen Gesamtzahlen der Galerie- und Fotoeinträge, ohne Aufschlüsselung nach Galerien. Nutzungsmarker speichern keine Personen, Objektkennungen, Zeitpunkte oder Häufigkeiten.",
|
||||||
"catalogSearch": "Funktionsname oder Schlüssel suchen",
|
"catalogSearch": "Funktionsname oder Schlüssel suchen",
|
||||||
"catalogEmpty": "Keine passenden Funktionen.",
|
"catalogEmpty": "Keine passenden Funktionen.",
|
||||||
"configuredLabel": "Konfiguriert",
|
"configuredLabel": "Verfügbarkeit / Konfiguration",
|
||||||
"usedLabel": "Genutzt",
|
"usedLabel": "Genutzt",
|
||||||
"configurationOnly": "Nur Konfiguration, die tatsächliche Nutzung wird nicht erfasst.",
|
"configurationOnly": "Nur Konfiguration, die tatsächliche Nutzung wird nicht erfasst.",
|
||||||
"versionDisclosure": "Diese Zustimmung gilt für usage.v4 / usage-consent.v4. Sie ersetzt die Frage „Erlaubt mindestens eine Galerie Downloads?“ durch „Hat mindestens eine Galerie Downloads abgeschaltet?“. Bestehende v1-, v2- und v3-Teilnahmen behalten ihren bisherigen Umfang, bis du sie ausdrücklich erweiterst. Identität und Rohhistorie bleiben erhalten, wartende Pakete werden vor dem Upgrade unverändert zugestellt. Lokale Nutzungsmarker beginnen erst nach der Bestätigung durch den Collector neu. Der erste v4-Bericht kann am nächsten aktiven UTC-Tag folgen. Das neue Signal wird vor der Bestätigung nicht erfasst.",
|
"versionDisclosure": "Diese Zustimmung gilt für usage.v5 / usage-consent.v5: 87 Funktionen und dieselben zwei Bestandszahlen. Neue Ja/Nein-Werte unterscheiden echte CMS-, Vorlagen-, Branding-, SEO-, Kategorie- und Ereignistyp-Änderungen sowie die Annahme echter Vorlagen-E-Mails durch den Mailtransport (auch im Hintergrund) von Standards, unverändertem Speichern, Vorschauen und Tests. Inhalte, Empfänger, Vorlagenkennungen, Aktionszeitpunkte und Häufigkeiten werden nicht erfasst. Die Beobachtung beginnt erst nach bestätigter Zustimmung; Nein bedeutet nicht, dass noch Standards verwendet werden. Frühere Versionen behalten bis zum Upgrade Bedeutung und Umfang. Wartende Pakete bleiben unverändert; Markierungen starten nach Bestätigung neu.",
|
||||||
"currentSchema": "Aktuelles Berichtsschema: {{schema}}",
|
"currentSchema": "Aktuelles Berichtsschema: {{schema}}",
|
||||||
"reviewUpgrade": "Erweiterten Umfang von usage.v4 ansehen",
|
"reviewUpgrade": "Erweiterten Umfang von usage.v5 ansehen",
|
||||||
"upgrade": "usage.v4 ausdrücklich zustimmen",
|
"upgrade": "usage.v5 ausdrücklich zustimmen",
|
||||||
"upgradeExplanation": "Deine bestehende Teilnahme behält ihren bisherigen Umfang. Sieh dir den erweiterten Katalog und die beiden Bestandszahlen an, bevor du dich entscheidest. Ablehnen beendet die Teilnahme nicht.",
|
"upgradeExplanation": "Deine bestehende Teilnahme behält ihren bisherigen Umfang. Sieh dir den erweiterten Katalog und die beiden Bestandszahlen an, bevor du dich entscheidest. Ablehnen beendet die Teilnahme nicht.",
|
||||||
"upgradePending": "Die signierte Erweiterung wartet auf die Bestätigung des Collectors. Bis dahin wird nur der bisher bestätigte Umfang erfasst. Versuche es erneut, wenn der Collector erreichbar ist, oder deaktiviere die Teilnahme, um zu stoppen und zu löschen.",
|
"upgradePending": "Die signierte Erweiterung wartet auf die Bestätigung des Collectors. Bis dahin wird nur der bisher bestätigte Umfang erfasst. Versuche es erneut, wenn der Collector erreichbar ist, oder deaktiviere die Teilnahme, um zu stoppen und zu löschen.",
|
||||||
"catalog": {
|
"catalog": {
|
||||||
@@ -425,6 +425,41 @@
|
|||||||
"gallery_downloads_restricted": {
|
"gallery_downloads_restricted": {
|
||||||
"name": "Galerie-Downloads eingeschränkt",
|
"name": "Galerie-Downloads eingeschränkt",
|
||||||
"configured": "Mindestens eine Galerie hat Downloads abgeschaltet; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
"configured": "Mindestens eine Galerie hat Downloads abgeschaltet; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||||
|
},
|
||||||
|
"cms_content_editing": {
|
||||||
|
"name": "CMS-Inhalte bearbeiten",
|
||||||
|
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||||
|
"used": "Ein Admin hat seit Zustimmung den Titel oder Inhalt einer internen CMS-Seite tatsächlich geändert. Unverändertes Speichern, externe Links, Logos, Standardseiten und Seitenaufrufe zählen nicht. Ob jemand die Seite gelesen hat, wird nicht gemessen."
|
||||||
|
},
|
||||||
|
"email_template_editing": {
|
||||||
|
"name": "E-Mail-Vorlagen anpassen",
|
||||||
|
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||||
|
"used": "Ein Admin hat seit Zustimmung eine nicht leere Vorlage erstellt oder Betreff/Inhalt tatsächlich geändert. Standardvorlagen, unverändertes Speichern, Vorschauen und Versand zählen nicht. Frühere Anpassungen lassen sich daraus nicht ableiten."
|
||||||
|
},
|
||||||
|
"branding_editing": {
|
||||||
|
"name": "Branding anpassen",
|
||||||
|
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||||
|
"used": "Ein Admin hat seit Zustimmung Branding-Einstellungen, ein Theme oder Logo geändert. Lesen und unverändertes Speichern zählen nicht. Auch das Wiederherstellen eines Standards kann eine Änderung sein; dies beschreibt nicht das aktuelle Design."
|
||||||
|
},
|
||||||
|
"seo_editing": {
|
||||||
|
"name": "SEO anpassen",
|
||||||
|
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||||
|
"used": "Ein Admin hat seit Zustimmung eine festgelegte SEO-Einstellung tatsächlich geändert. Standards, Lesen und unverändertes Speichern zählen nicht; Regeln, Pfade und Suchmaschinenaktivität werden nicht erfasst."
|
||||||
|
},
|
||||||
|
"event_type_editing": {
|
||||||
|
"name": "Ereignistypen anpassen",
|
||||||
|
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||||
|
"used": "Ein Admin hat seit Zustimmung einen Ereignistyp erstellt, geändert oder gelöscht. Standardvorlagen, Lesen und unverändertes Speichern zählen nicht. Namen, Vorlagen und Kennungen werden nicht gespeichert."
|
||||||
|
},
|
||||||
|
"category_editing": {
|
||||||
|
"name": "Kategorien anpassen",
|
||||||
|
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||||
|
"used": "Ein Admin hat seit Zustimmung eine Kategorie erstellt, geändert oder gelöscht. Standardkategorien, Lesen und unverändertes Speichern zählen nicht. Namen, Zuordnungen und Kennungen werden nicht gespeichert."
|
||||||
|
},
|
||||||
|
"email_template_delivery": {
|
||||||
|
"name": "E-Mails mit Vorlagen versendet",
|
||||||
|
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||||
|
"used": "Seit Zustimmung wurde mindestens eine echte Vorlagen-E-Mail vom SMTP-Transport oder Mail-Webhook angenommen, auch bei Hintergrundversand. Vorschauen, Testmails und Nachrichten ohne Vorlage zählen nicht. Dies belegt weder Empfang noch Lesen. Vorlagenkennung, Empfänger, Inhalte, Nachrichtenkennung, Versandzeit und Anzahl werden nicht in Nutzungsmarkierungen gespeichert."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auditTitle": "Export- und Löschquittungen",
|
"auditTitle": "Export- und Löschquittungen",
|
||||||
@@ -522,7 +557,9 @@
|
|||||||
"name": "Gespeicherte Fotoeinträge",
|
"name": "Gespeicherte Fotoeinträge",
|
||||||
"description": "Aktuelle Anzahl der Fotoeinträge ohne Videos, einschließlich RAW, Gast-Uploads und Einträgen archivierter Galerien. Eine Gesamtzahl der Installation; keine eindeutigen Dateien, Vorschaubilder, Verarbeitungserfolge oder Fotoinhalte. Gelöschte Einträge zählen nicht."
|
"description": "Aktuelle Anzahl der Fotoeinträge ohne Videos, einschließlich RAW, Gast-Uploads und Einträgen archivierter Galerien. Eine Gesamtzahl der Installation; keine eindeutigen Dateien, Vorschaubilder, Verarbeitungserfolge oder Fotoinhalte. Gelöschte Einträge zählen nicht."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"builtinLabel": "Fest integriert — Verfügbarkeit bedeutet keine Nutzung",
|
||||||
|
"enabledLabel": "Aktiviert — kann ein Standard sein"
|
||||||
},
|
},
|
||||||
"userManagement": {
|
"userManagement": {
|
||||||
"title": "Benutzerverwaltung",
|
"title": "Benutzerverwaltung",
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"productUsage": {
|
"productUsage": {
|
||||||
"fields": "usage.v4 reports contain an installation fingerprint, PicPeak version, UTC report date and generation time, schema/signing metadata, controlled gallery layouts, 86 fixed capability signals (63 configured/used pairs and 23 configuration-only booleans), and two installation totals: stored galleries and photo records excluding videos. Drafts and archived galleries and their retained photo records are included. The catalog below defines every field. There are no action counts or visitor observations.",
|
"fields": "usage.v5 reports contain an installation fingerprint, PicPeak version, UTC report date and generation time, schema/signing metadata, controlled gallery layouts, 87 fixed capability signals (64 configured/used pairs and 23 configuration-only booleans), and two installation totals: stored galleries and photo records excluding videos. Drafts and archived galleries and their retained photo records are included. The catalog below defines every field. There are no action counts or visitor observations.",
|
||||||
"catalogTitle": "Full catalog: 86 capability signals and 2 inventory totals (usage.v4)",
|
"catalogTitle": "Full catalog: 87 capability signals and 2 inventory totals (usage.v5)",
|
||||||
"catalogExplanation": "Configured describes current technical availability or configuration. Built-in means available, not used. Used is one installation-wide yes/no bit since consent to the reporting schema; accepted jobs mean initiated, not necessarily completed. Configuration-only capabilities omit used. Inventory contains only current gallery/photo record totals, with no per-gallery breakdown. No actor, entity identifier, action time or frequency is stored in usage markers.",
|
"catalogExplanation": "Configured describes current technical availability or configuration. Built-in means available, not used. Used is one installation-wide yes/no bit since consent to the reporting schema; accepted jobs mean initiated, not necessarily completed. Configuration-only capabilities omit used. Inventory contains only current gallery/photo record totals, with no per-gallery breakdown. No actor, entity identifier, action time or frequency is stored in usage markers.",
|
||||||
"catalogSearch": "Search capability name or key",
|
"catalogSearch": "Search capability name or key",
|
||||||
"catalogEmpty": "No matching capabilities.",
|
"catalogEmpty": "No matching capabilities.",
|
||||||
"configuredLabel": "Configured",
|
"configuredLabel": "Availability / configuration",
|
||||||
"usedLabel": "Used",
|
"usedLabel": "Used",
|
||||||
"configurationOnly": "Configuration only — actual use is not collected.",
|
"configurationOnly": "Configuration only — actual use is not collected.",
|
||||||
"versionDisclosure": "This consent covers usage.v4 / usage-consent.v4. It replaces the question “does any gallery allow downloads?” with “does any gallery have downloads switched off?”. Existing v1/v2/v3 participants keep their exact previous scope until they explicitly upgrade. Identity and raw history remain; pending packets are delivered unchanged before an upgrade. Local usage markers restart only after confirmation. The first v4 report may be on the next active UTC day. The new signal is never collected before confirmation.",
|
"versionDisclosure": "This consent covers usage.v5 / usage-consent.v5: 87 capabilities and the same two inventory totals. New booleans distinguish real CMS/template/branding/SEO/category/event-type edits and actual template-email transport acceptance, including background sends, from defaults, unchanged saves, previews and tests. No content, recipient, template key, action timestamp or frequency is collected. Observations begin only after accepted consent; false does not mean an installation still uses defaults. Previous versions keep their meanings and scope until upgrade; queued packets stay unchanged and markers restart after confirmation.",
|
||||||
"currentSchema": "Current reporting schema: {{schema}}",
|
"currentSchema": "Current reporting schema: {{schema}}",
|
||||||
"reviewUpgrade": "Review expanded usage.v4 scope",
|
"reviewUpgrade": "Review expanded usage.v5 scope",
|
||||||
"upgrade": "Explicitly agree to usage.v4",
|
"upgrade": "Explicitly agree to usage.v5",
|
||||||
"upgradeExplanation": "Your existing participation keeps its current scope. Review the expanded catalog and the two inventory totals before deciding whether to upgrade. Declining does not end participation.",
|
"upgradeExplanation": "Your existing participation keeps its current scope. Review the expanded catalog and the two inventory totals before deciding whether to upgrade. Declining does not end participation.",
|
||||||
"upgradePending": "The signed consent upgrade is pending confirmation. Only the previously accepted scope is collected. Retry when the collector is available, or disable participation to stop and delete.",
|
"upgradePending": "The signed consent upgrade is pending confirmation. Only the previously accepted scope is collected. Retry when the collector is available, or disable participation to stop and delete.",
|
||||||
"catalog": {
|
"catalog": {
|
||||||
@@ -425,6 +425,41 @@
|
|||||||
"gallery_downloads_restricted": {
|
"gallery_downloads_restricted": {
|
||||||
"name": "Gallery downloads restricted",
|
"name": "Gallery downloads restricted",
|
||||||
"configured": "At least one gallery has downloads switched off; only existence across the installation, never gallery IDs or counts."
|
"configured": "At least one gallery has downloads switched off; only existence across the installation, never gallery IDs or counts."
|
||||||
|
},
|
||||||
|
"cms_content_editing": {
|
||||||
|
"name": "CMS content editing",
|
||||||
|
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||||
|
"used": "An admin saved a real change to an internal CMS page title or body since consent. Unchanged saves, external links, logos, seeded pages and page views do not count. This does not measure whether anyone read the page."
|
||||||
|
},
|
||||||
|
"email_template_editing": {
|
||||||
|
"name": "Email template customization",
|
||||||
|
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||||
|
"used": "An admin created a nonempty template or saved a real subject/body change since consent. Defaults, unchanged saves, previews and sending are excluded. This does not establish the current customization of templates edited before consent."
|
||||||
|
},
|
||||||
|
"branding_editing": {
|
||||||
|
"name": "Branding customization",
|
||||||
|
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||||
|
"used": "An admin changed branding settings, a theme or a logo since consent. Reading settings and unchanged saves do not count. A change can also restore a default; this is not a claim about the current design."
|
||||||
|
},
|
||||||
|
"seo_editing": {
|
||||||
|
"name": "SEO customization",
|
||||||
|
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||||
|
"used": "An admin saved a real change to an allowlisted SEO setting since consent. Defaults, reading and unchanged saves do not count; no rules, paths or search-engine activity are collected."
|
||||||
|
},
|
||||||
|
"event_type_editing": {
|
||||||
|
"name": "Event type customization",
|
||||||
|
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||||
|
"used": "An admin created, changed or deleted an event type since consent. Seeded presets, reading and unchanged saves do not count. No names, presets or identifiers are retained."
|
||||||
|
},
|
||||||
|
"category_editing": {
|
||||||
|
"name": "Category customization",
|
||||||
|
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||||
|
"used": "An admin created, changed or deleted a category since consent. Seeded categories, reading and unchanged saves do not count. No names, memberships or identifiers are retained."
|
||||||
|
},
|
||||||
|
"email_template_delivery": {
|
||||||
|
"name": "Emails sent using templates",
|
||||||
|
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||||
|
"used": "At least one real template email was accepted by SMTP or the configured mail webhook since consent, including background sends. Previews, test messages and template-free messages are excluded. Acceptance does not prove receipt or reading. No template key, recipient, contents, message identifier, send time or count is stored in usage markers."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auditTitle": "Export and deletion receipts",
|
"auditTitle": "Export and deletion receipts",
|
||||||
@@ -522,7 +557,9 @@
|
|||||||
"name": "Stored photo records",
|
"name": "Stored photo records",
|
||||||
"description": "Current number of non-video photo records, including RAW, guest uploads and records of archived galleries. One total for the installation; not unique files, thumbnails, processing success or photo contents. Deleted records are excluded."
|
"description": "Current number of non-video photo records, including RAW, guest uploads and records of archived galleries. One total for the installation; not unique files, thumbnails, processing success or photo contents. Deleted records are excluded."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"builtinLabel": "Built in — availability is not use",
|
||||||
|
"enabledLabel": "Enabled — may be a default"
|
||||||
},
|
},
|
||||||
"userManagement": {
|
"userManagement": {
|
||||||
"title": "User Management",
|
"title": "User Management",
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ export const productUsageService = {
|
|||||||
async enable(): Promise<UsageStatus> {
|
async enable(): Promise<UsageStatus> {
|
||||||
return (
|
return (
|
||||||
await api.post('/admin/usage/enable', {
|
await api.post('/admin/usage/enable', {
|
||||||
consent_version: 'usage-consent.v4'
|
consent_version: 'usage-consent.v5'
|
||||||
})
|
})
|
||||||
).data;
|
).data;
|
||||||
},
|
},
|
||||||
async upgradeConsent(): Promise<{ delivered: boolean; queued: boolean; state: UsageStatus }> {
|
async upgradeConsent(): Promise<{ delivered: boolean; queued: boolean; state: UsageStatus }> {
|
||||||
return (await api.post('/admin/usage/consent', { consent_version: 'usage-consent.v4' })).data;
|
return (await api.post('/admin/usage/consent', { consent_version: 'usage-consent.v5' })).data;
|
||||||
},
|
},
|
||||||
async disable(): Promise<UsageStatus> {
|
async disable(): Promise<UsageStatus> {
|
||||||
return (await api.post('/admin/usage/disable')).data;
|
return (await api.post('/admin/usage/disable')).data;
|
||||||
|
|||||||
Reference in New Issue
Block a user