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:
Paul Nothaft
2026-09-07 20:03:15 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 69754f8a2c
commit 5c1e38d921
31 changed files with 5398 additions and 292 deletions
+2 -2
View File
@@ -201,8 +201,8 @@ test('public/gallery paths and failed/unauthenticated admin operations never set
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
});
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4'])('consent upgrade accepts exactly the explicit %s choice, never extra fields', async (consent_version) => {
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v5' }, { consent_version, user: 'PRIVATE' }])
test.each(['usage-consent.v2', 'usage-consent.v3', 'usage-consent.v4', 'usage-consent.v5'])('consent upgrade accepts exactly the explicit %s choice, never extra fields', async (consent_version) => {
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v6' }, { consent_version, user: 'PRIVATE' }])
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`).send(data).expect(400);
expect(service.command).not.toHaveBeenCalled();
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`)
@@ -0,0 +1,131 @@
const express = require('express');
const request = require('supertest');
const knex = require('knex');
const { changedFields, settingsChanged } = require('../../src/usage/adoptionEvidence');
jest.mock('../../src/middleware/auth', () => ({ adminAuth: (req, res, next) => { req.admin = { id: 1 }; next(); } }));
jest.mock('../../src/middleware/permissions', () => ({ requirePermission: () => (req, res, next) => next() }));
jest.mock('../../src/middleware/requireFeatureFlag', () => ({ requireFeatureFlag: () => (req, res, next) => next() }));
jest.mock('../../src/services/productUsageService', () => ({ markUsed: jest.fn().mockResolvedValue() }));
jest.mock('../../src/services/emailProcessor', () => ({
htmlToText: body => body, wrapEmailHtml: jest.fn(async body => body), buildSignatureTextFor: jest.fn(async () => ''),
}));
jest.mock('../../src/services/businessProfileService', () => ({ getEmailSignature: jest.fn(async () => null) }));
describe('v5 evidence comes from real edits, not the generic successful-route marker', () => {
let db, app;
const marker = require('../../src/services/productUsageService').markUsed;
const recorded = () => marker.mock.calls.flatMap(([keys]) => keys).filter(key => /_editing$/.test(key));
beforeAll(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
jest.doMock('../../src/database/db', () => ({ db, logActivity: jest.fn(async () => {}) }));
await db.schema.createTable('cms_pages', t => {
t.increments('id'); t.string('slug');
for (const key of ['title_en', 'title_de', 'content_en', 'content_de', 'logo_url', 'external_url']) t.text(key);
t.boolean('use_external_url').defaultTo(false); t.boolean('show_in_footer').defaultTo(true); t.timestamp('updated_at');
});
await db.schema.createTable('email_templates', t => { t.increments('id'); t.string('template_key'); t.timestamp('updated_at'); });
await db.schema.createTable('email_template_translations', t => {
t.increments('id'); t.integer('template_id'); t.string('language');
for (const key of ['subject', 'body_html', 'body_text']) t.text(key);
t.timestamp('updated_at'); t.timestamp('created_at');
});
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
await db.schema.createTable('event_types', t => {
t.increments('id'); for (const key of ['name', 'slug_prefix', 'emoji', 'theme_preset', 'theme_config']) t.text(key);
t.integer('display_order'); t.boolean('is_active'); t.boolean('is_system'); t.timestamp('updated_at'); t.timestamp('created_at');
});
await db.schema.createTable('events', t => { t.increments('id'); t.integer('created_by'); });
await db.schema.createTable('event_category_order', t => { t.integer('event_id'); t.integer('category_id'); t.integer('position'); });
await db.schema.createTable('photo_categories', t => {
t.increments('id'); t.text('name'); t.text('slug'); t.integer('hero_photo_id'); t.integer('event_id');
t.integer('display_order'); t.boolean('allow_downloads'); t.boolean('is_folder'); t.boolean('is_global');
});
app = express(); app.use(express.json());
app.use(require('../../src/middleware/productUsage').productUsage);
app.use('/cms', require('../../src/routes/adminCMS'));
app.use('/email', require('../../src/routes/adminEmail'));
app.use('/event-types', require('../../src/routes/adminEventTypes'));
app.use('/categories', require('../../src/routes/adminCategories'));
});
beforeEach(async () => {
marker.mockClear();
for (const table of ['cms_pages', 'email_templates', 'email_template_translations', 'app_settings', 'event_types', 'photo_categories', 'events', 'event_category_order']) await db(table).delete();
await db('cms_pages').insert({ slug: 'privacy', title_en: 'Privacy', content_en: 'Seeded content' });
await db('email_templates').insert({ id: 1, template_key: 'PRIVATE-template' });
await db('email_template_translations').insert({ template_id: 1, language: 'en', subject: 'Seeded subject', body_html: 'Seeded body', body_text: '' });
});
afterAll(() => db.destroy());
test('CMS reads, unchanged saves and external content do not imply internal content editing', async () => {
await request(app).get('/cms/pages').expect(200);
await request(app).put('/cms/pages/privacy').send({ content_en: 'Seeded content' }).expect(200);
await request(app).put('/cms/pages/privacy').send({ use_external_url: true, external_url: 'https://example.test/privacy', content_en: 'Other' }).expect(200);
expect(recorded()).toEqual([]);
await request(app).put('/cms/pages/privacy').send({ use_external_url: false, content_en: 'PRIVATE real content' }).expect(200);
expect(recorded()).toEqual(['cms_content_editing']);
expect(JSON.stringify(marker.mock.calls)).not.toMatch(/PRIVATE|example\.test|privacy/);
});
test('template preview, empty and unchanged saves are excluded; actual content changes count', async () => {
await request(app).post('/email/templates/PRIVATE-template/preview').send({}).expect(200);
await request(app).put('/email/templates/PRIVATE-template').send({ translations: {} }).expect(200);
const translation = { subject: 'Seeded subject', body_html: 'Seeded body', body_text: '' };
await request(app).put('/email/templates/PRIVATE-template').send({ translations: { en: translation } }).expect(200);
expect(recorded()).toEqual([]);
await request(app).put('/email/templates/PRIVATE-template').send({ translations: { en: { ...translation, subject: 'PRIVATE custom subject' } } }).expect(200);
expect(recorded()).toEqual(['email_template_editing']);
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
});
test('failed saves do not count; new nonempty templates do', async () => {
await request(app).put('/email/templates/missing').send({ translations: {} }).expect(404);
await request(app).post('/email/templates').send({ template_key: 'empty', translations: {} }).expect(201);
expect(recorded()).toEqual([]);
await request(app).post('/email/templates').send({ template_key: 'custom', translations: { de: { subject: 'Privat' } } }).expect(201);
expect(recorded()).toEqual(['email_template_editing']);
});
test('seeded event types and categories count only after a real edit, not identical saves', async () => {
await db('event_types').insert({ id: 1, name: 'Wedding', slug_prefix: 'wedding', is_active: true, is_system: true });
await db('photo_categories').insert({ id: 1, name: 'All', slug: 'all', is_global: true, is_folder: false });
await request(app).get('/event-types').expect(200);
await request(app).get('/categories/global').expect(200);
await request(app).put('/event-types/1').send({ name: 'Wedding', is_active: true }).expect(200);
await request(app).put('/categories/1').send({ name: 'All', is_folder: false }).expect(200);
expect(recorded()).toEqual([]);
await request(app).put('/event-types/1').send({ name: 'PRIVATE event type' }).expect(200);
await request(app).put('/categories/1').send({ name: 'PRIVATE category' }).expect(200);
expect(recorded()).toEqual(['event_type_editing', 'category_editing']);
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
});
test('reordering event types and global categories counts only when the order changes', async () => {
await db('event_types').insert([
{ id: 1, name: 'A', slug_prefix: 'a', display_order: 1, is_active: true, is_system: true },
{ id: 2, name: 'B', slug_prefix: 'b', display_order: 2, is_active: true, is_system: true },
]);
await db('photo_categories').insert([
{ id: 1, name: 'A', slug: 'a', is_global: true, is_folder: false, display_order: 1 },
{ id: 2, name: 'B', slug: 'b', is_global: true, is_folder: false, display_order: 2 },
]);
await db('events').insert({ id: 1 });
await request(app).post('/event-types/reorder').send({ orderedIds: [1, 2] }).expect(200);
await request(app).post('/categories/reorder-global').send({ orderedIds: [1, 2] }).expect(200);
await request(app).delete('/categories/reorder/1').expect(200); // no override to reset
expect(recorded()).toEqual([]);
await request(app).post('/event-types/reorder').send({ orderedIds: [2, 1] }).expect(200);
await request(app).post('/categories/reorder-global').send({ orderedIds: [2, 1] }).expect(200);
await request(app).post('/categories/reorder').send({ event_id: 1, orderedIds: [2, 1] }).expect(200);
expect(recorded()).toEqual(['event_type_editing', 'category_editing', 'category_editing']);
marker.mockClear();
await request(app).post('/categories/reorder').send({ event_id: 1, orderedIds: [2, 1] }).expect(200); // same override again
expect(recorded()).toEqual([]);
await request(app).delete('/categories/reorder/1').expect(200);
expect(recorded()).toEqual(['category_editing']);
});
test('settings compare persisted values, not timestamps, JSON order or defaults materialized as rows', async () => {
await db('app_settings').insert({ setting_key: 'theme_config', setting_value: JSON.stringify({ a: 1, b: 2 }) });
expect(await settingsChanged(db, { theme_config: { b: 2, a: 1 } }, ['theme_config'])).toBe(false);
expect(await settingsChanged(db, { theme_config: { b: 2, a: 2 } }, ['theme_config'])).toBe(true);
expect(await settingsChanged(db, { missing: 'fallback' }, ['missing'])).toBe(false);
expect(await settingsChanged(db, { secret: 'PRIVATE' }, ['theme_config'])).toBe(false);
expect(changedFields({ enabled: 1, updated_at: 'old' }, { enabled: true, updated_at: 'new' }, ['enabled'])).toBe(false);
expect(changedFields({ body: '' }, { body: null }, ['body'])).toBe(false);
});
});
@@ -1,8 +1,8 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const catalog = require('../../src/usage/features.v4.json');
const inventory = require('../../../docs/usage-coverage.v4.json');
const catalog = require('../../src/usage/features.v5.json');
const inventory = require('../../../docs/usage-coverage.v5.json');
const protocol = require('../../src/usage/schema.cjs');
const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules');
const { acceptedUpload, capabilityEvidence } = require('../../src/usage/capabilityEvidence');
@@ -52,22 +52,22 @@ test('all current settings tabs have an explicit scope decision', () => {
}
});
test('v1/v2/v3 wire validation is immutable; v4 catalog, UI and translated descriptions agree', () => {
test('v1/v2/v3 wire validation is immutable; v5 catalog, UI and translated descriptions agree', () => {
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex'))
.toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922');
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v2'].properties)).digest('hex'))
.toBe('159821cf45c1951016d33a4ed9ca55a0a7ee1b60dd715b803fcfed33e5c8a846');
expect(protocol.FEATURE_KEYS).toHaveLength(86);
expect(protocol.FEATURE_KEYS).toHaveLength(87);
expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v3'].properties)).digest('hex'))
.toBe('93214702c79f47823f154544ebad6612dd313604f69e60b86de4c0e4c904571a');
expect(protocol.FEATURE_KEYS).toContain('gallery_downloads_restricted');
expect(protocol.FEATURE_KEYS).not.toContain('gallery_downloads');
expect(protocol.ALL_FEATURE_KEYS).toHaveLength(87);
expect(protocol.ALL_FEATURE_KEYS).toHaveLength(94);
expect(protocol.ALL_FEATURE_KEYS).toContain('gallery_downloads');
expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19);
expect(inventory.configuration_only).toHaveLength(23);
const frontend = path.resolve(__dirname, '../../../frontend');
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v4.json')))).toEqual(catalog);
expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v5.json')))).toEqual(catalog);
// The catalog is source, and source is English only: its strings are the
// en locale verbatim. Every other language lives in its locale file and
// must cover every key and field, but says whatever its translator chose.
@@ -85,11 +85,11 @@ test('v1/v2/v3 wire validation is immutable; v4 catalog, UI and translated descr
});
test('every used field has either a fixed route rule or explicit trusted success evidence', () => {
const explicit = ['custom_css', 'oauth', 'smtp', 'email_webhook', 'whatsapp', 'incoming_mail',
const explicit = ['cms_content_editing', 'email_template_editing', 'email_template_delivery', 'branding_editing', 'seo_editing', 'event_type_editing', 'category_editing', 'custom_css', 'oauth', 'smtp', 'email_webhook', 'whatsapp', 'incoming_mail',
'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage', 's3_backups', 'api_integration', 'photo_xmp_export', 'photo_replacement', 'photo_admin_marks', 'crm_invoice_import', 'crm_combined_billing', 'crm_monthly_billing_manual', 'crm_document_conversion'];
const covered = new Set([...explicit, ...RULES_V2.flatMap(([, , keys]) => keys)]);
expect(protocol.FEATURE_KEYS.filter((key) => protocol.observesUse(key)).filter((key) => !covered.has(key))).toEqual([]);
for (const key of covered) expect(protocol.observesUse(key)).toBe(true);
for (const key of covered) expect(protocol.ALL_FEATURES[key].used).toBeTruthy();
});
test.each([
@@ -7,13 +7,13 @@ const signHistorical = (packet, id) => {
return { ...signed, signature: crypto.sign(null, Buffer.from(p.canonical(signed)), id.private_key).toString('base64url') };
};
describe.each(['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4'])('%s receiver compatibility never loosens the PicPeak sender', version => {
describe.each(['usage.v1', 'usage.v2', 'usage.v3', 'usage.v4', 'usage.v5'])('%s receiver compatibility never loosens the PicPeak sender', version => {
test('complete original reports still sign and verify', () => {
const id = p.generateIdentity();
const packet = p.makePacket(id, 'report', 1, {
picpeak_version: '1.0.0', report_date: '2026-09-06', generated_at: new Date(now).toISOString(),
features: p.emptyFeatures(version), gallery_layouts: [],
...(['usage.v3', 'usage.v4'].includes(version) ? { inventory: { galleries: 0, photos: 0 } } : {}),
...(['usage.v3', 'usage.v4', 'usage.v5'].includes(version) ? { inventory: { galleries: 0, photos: 0 } } : {}),
}, version);
const envelope = p.signPacket(packet, id, new Date(now));
expect(p.verifyEnvelope(envelope, now)).toEqual(packet);
@@ -67,6 +67,14 @@ test('the collector has no way in: no inbound route and no scheduled pull', () =
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
if (entry.name === 'productUsageService.js') continue;
if (entry.name === 'emailProcessor.js') {
// v5 explicitly consents to one local background-mail bit, never a send
// to the collector. No mail details may be passed into the usage API.
const email = fs.readFileSync(path.join(dir, entry.name), 'utf8');
expect(email).toContain(".markUsed(['email_template_delivery'])");
expect(email).not.toMatch(/productUsageService'\)\.(?:tick|enable|command|deliver)/);
continue;
}
expect(fs.readFileSync(path.join(dir, entry.name), 'utf8'))
.not.toContain('productUsageService');
}
@@ -0,0 +1,82 @@
const knex = require('knex');
jest.mock('nodemailer', () => ({ createTransport: jest.fn() }));
jest.mock('../../src/services/emailWebhookTransport', () => ({ isEnabled: jest.fn(), send: jest.fn() }));
jest.mock('../../src/services/productUsageService', () => ({ markUsed: jest.fn().mockResolvedValue() }));
jest.mock('../../src/services/businessProfileService', () => ({ getEmailSignature: jest.fn(async () => null) }));
describe('template delivery is a coarse transport-acceptance bit', () => {
let db, sendTemplateEmail, queueEmail, processEmailQueue;
const sendMail = jest.fn();
const webhook = require('../../src/services/emailWebhookTransport');
const marker = require('../../src/services/productUsageService').markUsed;
beforeAll(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
jest.doMock('../../src/database/db', () => ({ db }));
await db.schema.createTable('email_configs', t => {
t.increments('id'); for (const key of ['smtp_host', 'smtp_user', 'smtp_pass', 'from_name', 'from_email']) t.string(key);
t.integer('smtp_port'); t.boolean('smtp_secure'); t.boolean('tls_reject_unauthorized');
});
await db('email_configs').insert({ smtp_host: 'smtp.example.test', smtp_port: 587, from_email: '[email protected]' });
await db.schema.createTable('email_templates', t => { t.increments('id'); t.string('template_key'); t.text('subject'); t.text('body_html'); });
await db('email_templates').insert({ template_key: 'PRIVATE-template', subject: 'PRIVATE subject', body_html: '<p>PRIVATE body</p>' });
await db.schema.createTable('app_settings', t => { t.string('setting_key').primary(); t.text('setting_value'); });
await db.schema.createTable('email_queue', t => {
t.increments('id'); t.integer('event_id'); t.integer('campaign_id'); t.string('recipient_email'); t.string('email_type');
t.text('email_data'); t.string('status'); t.integer('retry_count'); t.text('error_message'); t.text('rendered_html');
t.timestamp('scheduled_at'); t.timestamp('created_at'); t.timestamp('sent_at');
});
require('nodemailer').createTransport.mockReturnValue({ sendMail, verify: jest.fn(async () => true) });
({ sendTemplateEmail, queueEmail, processEmailQueue } = require('../../src/services/emailProcessor'));
});
beforeEach(() => {
marker.mockClear(); marker.mockResolvedValue(); webhook.isEnabled.mockReturnValue(false);
sendMail.mockReset(); sendMail.mockResolvedValue({ messageId: 'PRIVATE-message', accepted: ['[email protected]'] });
});
afterAll(() => db.destroy());
const send = options => sendTemplateEmail('[email protected]', 'PRIVATE-template', { __language: 'en' }, options);
test('successful real SMTP send transmits only the fixed capability key', async () => {
await send();
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
expect(JSON.stringify(marker.mock.calls)).not.toContain('PRIVATE');
});
test('rejected recipients, failed sends, missing templates and explicit tests do not count', async () => {
await send({ usageEligible: false });
sendMail.mockResolvedValueOnce({ messageId: 'private', accepted: [] }); await send();
sendMail.mockRejectedValueOnce(new Error('mail failed')); await expect(send()).rejects.toThrow('mail failed');
await expect(sendTemplateEmail('[email protected]', 'missing', { __language: 'en' })).rejects.toThrow('not found');
expect(marker).not.toHaveBeenCalled();
});
test('a queued test message keeps its exclusion through the queue processor; a queued real one counts', async () => {
await queueEmail(null, '[email protected]', 'PRIVATE-template', { __language: 'en' }, { usageEligible: false });
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(1);
expect(marker).not.toHaveBeenCalled();
await queueEmail(null, '[email protected]', 'PRIVATE-template', { __language: 'en' });
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(2);
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
expect(await db('email_queue').where('status', 'sent').count({ n: '*' }).first()).toMatchObject({ n: 2 });
});
test('a workflow test run (engine.testRun, __test) queues a test message; a real run counts', async () => {
require('../../src/services/workflows/actions');
const sendEmail = require('../../src/services/workflows/registry').getAction('send_email');
const ctx = (vars) => ({ node: { config: { to: '[email protected]', emailType: 'PRIVATE-template', recipientClass: 'admin' } }, vars });
await sendEmail(ctx({ __test: true, emailData: { __language: 'en' } }));
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(1);
expect(marker).not.toHaveBeenCalled();
await sendEmail(ctx({ emailData: { __language: 'en' } }));
await processEmailQueue();
expect(sendMail).toHaveBeenCalledTimes(2);
expect(marker).toHaveBeenCalledWith(['email_template_delivery']);
});
test('webhook success counts; failure does not; marker failure never retries successful mail', async () => {
webhook.isEnabled.mockReturnValue(true); webhook.send.mockResolvedValue({ messageId: 'private' });
marker.mockRejectedValueOnce(new Error('usage unavailable'));
await expect(send()).resolves.toMatchObject({ success: true });
expect(webhook.send).toHaveBeenCalledTimes(1);
marker.mockClear(); webhook.send.mockRejectedValueOnce(new Error('webhook failed'));
await expect(send()).rejects.toThrow('webhook failed');
expect(marker).not.toHaveBeenCalled();
});
});
+3
View File
@@ -1,3 +1,4 @@
const { changedEvidence } = require('../usage/adoptionEvidence');
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
@@ -144,6 +145,8 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
if (!updated.use_external_url) changedEvidence(res, 'cms_content_editing', page, updated,
['title_en', 'title_de', 'content_en', 'content_de']);
res.json(updated);
} catch (error) {
logger.error('Error updating CMS page:', error);
+14 -2
View File
@@ -1,3 +1,5 @@
const { changedEvidence } = require('../usage/adoptionEvidence');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const express = require('express');
const { body, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
@@ -123,6 +125,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'category_editing');
res.json(category);
} catch (error) {
logger.error('Error creating category:', error);
@@ -206,6 +209,8 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
changedEvidence(res, 'category_editing', category, updated,
['name', 'slug', 'hero_photo_id', 'allow_downloads', 'is_folder']);
res.json(updated);
} catch (error) {
logger.error('Error updating category:', error);
@@ -260,6 +265,7 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
changedEvidence(res, 'category_editing', category, updated, ['hero_photo_id']);
res.json(updated);
} catch (error) {
logger.error('Error updating category hero:', error);
@@ -294,6 +300,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'category_editing');
res.json({ message: 'Category deleted successfully' });
} catch (error) {
logger.error('Error deleting category:', error);
@@ -346,12 +353,14 @@ router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
return res.status(400).json({ error: 'One or more categories are not available for this event' });
}
const before = await db('event_category_order').where('event_id', eventId).orderBy('position', 'asc').pluck('category_id');
await db.transaction(async (trx) => {
await trx('event_category_order').where('event_id', eventId).del();
await trx('event_category_order').insert(
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
);
});
changedEvidence(res, 'category_editing', { order: before }, { order: orderedIds }, ['order']);
// Log activity after commit (avoids a SQLite in-transaction global write).
await logActivity('event_category_order_set',
@@ -371,7 +380,8 @@ router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), requireEventOwnership, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId, 10);
await db('event_category_order').where('event_id', eventId).del();
const removed = await db('event_category_order').where('event_id', eventId).del();
if (removed > 0) capabilityEvidence(res, 'category_editing');
await logActivity('event_category_order_reset',
{ eventId },
@@ -400,7 +410,8 @@ router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id');
const globals = await db('photo_categories').where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc').orderBy('name', 'asc').pluck('id');
const globalsSet = new Set(globals);
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
if (invalid.length > 0) {
@@ -423,6 +434,7 @@ router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
.where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc');
changedEvidence(res, 'category_editing', { order: globals }, { order: categories.map((category) => category.id) }, ['order']);
res.json(categories);
} catch (error) {
logger.error('Error reordering global categories:', error);
+1 -1
View File
@@ -443,7 +443,7 @@ router.post(
const frontendUrl = await getAbsoluteFrontendUrl(req);
const payload = await buildPayloadFor(req.body.templateKey, req.admin.id, frontendUrl);
await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload);
await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload, { usageEligible: false });
return successResponse(res, {
sent: true,
+6
View File
@@ -1,3 +1,4 @@
const { changedFields } = require('../usage/adoptionEvidence');
const express = require('express');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const nodemailer = require('nodemailer');
@@ -1029,6 +1030,7 @@ router.put('/templates/:key', [
return res.status(400).json({ error: 'translations object is required' });
}
let contentChanged = false;
// Upsert each language translation
for (const [language, data] of Object.entries(translations)) {
if (!data || typeof data !== 'object') continue;
@@ -1044,6 +1046,7 @@ router.put('/templates/:key', [
updated_at: new Date(),
};
contentChanged ||= changedFields(existing, row, ['subject', 'body_html', 'body_text']);
if (existing) {
await db('email_template_translations')
.where({ template_id: template.id, language })
@@ -1091,6 +1094,7 @@ router.put('/templates/:key', [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
if (contentChanged) capabilityEvidence(res, 'email_template_editing');
res.json({ message: 'Email template updated successfully' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update email template');
@@ -1178,6 +1182,8 @@ router.post('/templates', [
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username });
if (Object.values(translations).some(content => content && changedFields({}, content,
['subject', 'body_html', 'body_text']))) capabilityEvidence(res, 'email_template_editing');
return res.status(201).json({ template_key: templateKey, id: templateId });
} catch (error) {
return errorResponse(res, error, 500, 'Failed to create email template');
+9
View File
@@ -1,3 +1,5 @@
const { changedEvidence } = require('../usage/adoptionEvidence');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
/**
* Admin Event Types Routes
* CRUD operations for managing customizable event types
@@ -123,6 +125,7 @@ router.post('/', adminAuth, requirePermission('event_types.manage'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'event_type_editing');
res.status(201).json(eventType);
} catch (error) {
logger.error('Error creating event type:', { error: error.message });
@@ -163,7 +166,10 @@ router.put('/:id', adminAuth, requirePermission('event_types.manage'), [
const { id } = req.params;
const updates = req.body;
const before = await eventTypeService.getEventTypeById(parseInt(id));
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
changedEvidence(res, 'event_type_editing', before, eventType,
['name', 'slug_prefix', 'emoji', 'theme_preset', 'theme_config', 'display_order', 'is_active']);
// Log activity
await logActivity('event_type_updated',
@@ -210,6 +216,7 @@ router.delete('/:id', adminAuth, requirePermission('event_types.manage'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'event_type_editing');
res.json({ message: 'Event type deleted successfully' });
} catch (error) {
logger.error('Error deleting event type:', { error: error.message });
@@ -240,7 +247,9 @@ router.post('/reorder', adminAuth, requirePermission('event_types.manage'), [
}
const { orderedIds } = req.body;
const before = (await eventTypeService.getAllEventTypes()).map((type) => type.id);
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
changedEvidence(res, 'event_type_editing', { order: before }, { order: eventTypes.map((type) => type.id) }, ['order']);
// Log activity
await logActivity('event_types_reordered',
+3
View File
@@ -1,3 +1,4 @@
const { capabilityEvidence } = require('../../usage/capabilityEvidence');
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
// Exports a register function; ./index.js calls the sub-routers in the original
// registration order so Express route matching is unchanged.
@@ -89,6 +90,7 @@ module.exports = (router) => {
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'branding_editing');
res.json({
message: 'Event logo uploaded successfully',
hero_logo_url: logoUrl
@@ -135,6 +137,7 @@ module.exports = (router) => {
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
if (event.hero_logo_url || event.hero_logo_path) capabilityEvidence(res, 'branding_editing');
res.json({ message: 'Event logo removed successfully' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to delete event logo');
+17
View File
@@ -1,3 +1,7 @@
const { settingsChanged } = require('../usage/adoptionEvidence');
const { capabilityEvidence } = require('../usage/capabilityEvidence');
const SEO_USAGE_KEYS = ['seo_allow_indexing', 'seo_block_ai_crawlers', 'seo_block_social_bots',
'seo_blocked_ai_agents', 'seo_custom_rules', 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', 'seo_sitemap_url'];
const express = require('express');
const multer = require('multer');
const path = require('path');
@@ -1036,6 +1040,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment })
};
const brandingUpdates = Object.fromEntries(Object.entries(brandingSettings).map(([key, value]) => [`branding_${key}`, value]));
const brandingChanged = await settingsChanged(db, brandingUpdates, Object.keys(brandingUpdates));
// Handle favicon deletion if empty string or null is provided
if (favicon_url === '' || favicon_url === null || favicon_url === undefined) {
// Get current favicon path to delete file
@@ -1125,6 +1132,7 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
metadata: JSON.stringify({ company_name })
});
if (brandingChanged) capabilityEvidence(res, 'branding_editing');
clearPublicSiteCache();
// Check if watermark settings changed and trigger regeneration
@@ -1229,6 +1237,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
updated_at: new Date()
});
capabilityEvidence(res, 'branding_editing');
res.json({
message: 'Logo uploaded successfully',
logoUrl: publicPath
@@ -1247,6 +1256,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
const logoChanged = await settingsChanged(db, { [pathKey]: '', [urlKey]: '' }, [pathKey, urlKey]);
const pathSetting = await db('app_settings').where('setting_key', pathKey).first();
if (pathSetting && pathSetting.setting_value) {
try {
@@ -1261,6 +1271,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
.whereIn('setting_key', [pathKey, urlKey])
.update({ setting_value: JSON.stringify(''), updated_at: new Date() });
if (logoChanged) capabilityEvidence(res, 'branding_editing');
res.json({ message: 'Logo removed' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to remove logo');
@@ -1346,6 +1357,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
watermarkRegenerationStarted = true;
}
capabilityEvidence(res, 'branding_editing');
res.json({
message: 'Watermark logo uploaded successfully',
watermarkLogoUrl: publicPath,
@@ -1360,6 +1372,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const themeSettings = req.body;
const themeChanged = await settingsChanged(db, { theme_config: themeSettings }, ['theme_config']);
// Save theme settings
await db('app_settings')
@@ -1386,6 +1399,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
clearPublicSiteCache();
if (themeChanged) capabilityEvidence(res, 'branding_editing');
res.json({ message: 'Theme settings updated successfully' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update theme settings');
@@ -1709,6 +1723,7 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
}
}
const seoChanged = await settingsChanged(db, settings, SEO_USAGE_KEYS);
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
await db('app_settings')
@@ -1738,6 +1753,7 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
});
if (seoChanged) capabilityEvidence(res, 'seo_editing');
res.json({ message: 'SEO settings updated successfully' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update SEO settings');
@@ -2035,6 +2051,7 @@ router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUp
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
capabilityEvidence(res, 'branding_editing');
res.json({ faviconUrl });
} catch (error) {
errorResponse(res, error, 500, 'Failed to upload favicon');
+1 -1
View File
@@ -82,7 +82,7 @@ router.post(
router.post(
'/consent',
wrap(async (req, res) => {
if (!req.body || Object.keys(req.body).length !== 1 || !['usage.v2', 'usage.v3', 'usage.v4'].includes(schemaForConsent(req.body.consent_version)))
if (!req.body || Object.keys(req.body).length !== 1 || !schemaForConsent(req.body.consent_version) || req.body?.consent_version === 'usage-consent.v1')
throw new ValidationError('Explicit usage consent is required');
res.json(await service.command('consent', { consent_version: req.body.consent_version }));
})
+17 -2
View File
@@ -906,7 +906,7 @@ async function buildSignatureTextFor(language) {
}
}
async function sendTemplateEmail(to, templateKey, variables) {
async function sendTemplateEmail(to, templateKey, variables, { usageEligible = true } = {}) {
try {
// Webhook transport (#1225) replaces SMTP entirely when configured, so an
// instance using it has no SMTP settings to initialise and must not be
@@ -987,6 +987,17 @@ async function sendTemplateEmail(to, templateKey, variables) {
? await emailWebhookTransport.send(mail)
: await transporter.sendMail(mail);
// Transport acceptance is the measured event, not rendering or inbox
// delivery. The service accepts only the fixed bit under confirmed v5
// consent; marker failure must never retry an already-sent message.
if (usageEligible && (viaWebhook || info.accepted?.length > 0)) {
try {
await require('./productUsageService').markUsed(['email_template_delivery']);
} catch {
logger.warn('Product usage mail marker could not be recorded');
}
}
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
// Return the rendered HTML so the queue processor can persist the ACTUAL
// sent body (email_queue.rendered_html) for the Project Overview preview.
@@ -1275,7 +1286,8 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
sendResult = await sendTemplateEmail(
email.recipient_email,
email.email_type,
emailData
emailData,
{ usageEligible: emailData.__usageEligible !== false }
);
}
@@ -1436,6 +1448,9 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options
try {
// Add eventId to emailData for language detection
emailData.eventId = eventId;
// An explicit test message (dev tools' send-test-email) must not count as
// template delivery when the queue processor sends it later.
if (options.usageEligible === false) emailData.__usageEligible = false;
const row = {
event_id: eventId,
recipient_email: recipientEmail,
@@ -231,7 +231,7 @@ async function sendTestUpdateNotification() {
current_version: updateInfo.current,
channel: channelLabel,
recipient_email: email
});
}, { usageEligible: false });
successCount++;
} catch (error) {
errorCount++;
+3 -2
View File
@@ -53,7 +53,8 @@ registry.registerAction('send_email', async (ctx) => {
// INTERNAL/admin = immediate; EXTERNAL/customer = business-hours floor.
const respectBusinessHours = !isInternal;
await emailProcessor.queueEmail(eventId, to, emailType, emailData, { respectBusinessHours });
// A workflow test run (engine.testRun, __test) is a test message for usage.
await emailProcessor.queueEmail(eventId, to, emailType, emailData, { respectBusinessHours, usageEligible: !ctx.vars?.__test });
return { sent_to: to, recipientClass, respectBusinessHours };
});
@@ -120,7 +121,7 @@ registry.registerAction('escalate_to_collections', async (ctx) => {
due_date: invoice.due_date ? String(invoice.due_date).slice(0, 10) : '',
reminder_level: invoice.reminder_level || 0,
attachments,
}, { respectBusinessHours: false }); // internal/admin → immediate
}, { respectBusinessHours: false, usageEligible: !ctx.vars?.__test }); // internal/admin → immediate
return { collections_handoff_to: adminEmail, outstanding };
});
+3 -3
View File
@@ -16,7 +16,7 @@ const {
verifyEnvelope,
digest,
canonical,
FEATURE_KEYS,
ALL_FEATURE_KEYS,
LEGACY_FEATURE_KEYS,
CATALOG,
CURRENT_SCHEMA_VERSION,
@@ -796,7 +796,7 @@ class UsageService {
async markUsed(features, { destinationBackup = false, legacyFeatures } = {}) {
let allowed = [...new Set([...features, ...(legacyFeatures || [])])].filter((f) =>
FEATURE_KEYS.includes(f)
ALL_FEATURE_KEYS.includes(f)
);
if (!allowed.length) return;
// Single-transaction status check prevents opt-out racing a late marker.
@@ -960,7 +960,7 @@ class UsageService {
generated_at: now,
features: expanded,
gallery_layouts: [...layouts].sort(),
...(['usage.v3', 'usage.v4'].includes(version) ? { inventory: await require('./inventorySnapshot').inventorySnapshot(this.db) } : {})
...(['usage.v3', 'usage.v4', 'usage.v5'].includes(version) ? { inventory: await require('./inventorySnapshot').inventorySnapshot(this.db) } : {})
};
}
+52
View File
@@ -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 };
+2 -2
View File
@@ -1,5 +1,5 @@
'use strict';
const { FEATURE_KEYS, observesUse } = require('./schema.cjs');
const { ALL_FEATURE_KEYS, ALL_FEATURES } = require('./schema.cjs');
// Trusted route handlers call this AFTER their business operation succeeds.
// Only fixed, allowlisted keys reach finish middleware. It still requires an
@@ -7,7 +7,7 @@ const { FEATURE_KEYS, observesUse } = require('./schema.cjs');
function capabilityEvidence(res, ...keys) {
res.locals.productUsageFeatures = [...new Set([
...(res.locals.productUsageFeatures || []),
...keys.filter((key) => FEATURE_KEYS.includes(key) && observesUse(key))
...keys.filter((key) => ALL_FEATURE_KEYS.includes(key) && ALL_FEATURES[key]?.used)
])];
}
function acceptedUpload(res, { video = false, raw = false, s3 = false } = {}) {
+2 -2
View File
@@ -85,7 +85,7 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage
gallery_guest_uploads: 'allow_user_uploads',
gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads'
})) result[key].configured = await enabled('events', column);
if (version === 'usage.v4') {
if (['usage.v4', 'usage.v5'].includes(version)) {
result.gallery_downloads_restricted.configured = await exists('events', ['allow_downloads'], (query) =>
query.where('allow_downloads', formatBoolean(false)));
} else {
@@ -122,7 +122,7 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage
query.where({ feedback_enabled: formatBoolean(true), [column]: formatBoolean(true) }));
result.gallery_guest_accounts.configured = await exists('event_feedback_settings', ['feedback_enabled', 'identity_mode'], (query) =>
query.where('feedback_enabled', formatBoolean(true)).whereIn('identity_mode', ['guest', 'shared']));
if (['usage.v3', 'usage.v4'].includes(version)) {
if (['usage.v3', 'usage.v4', 'usage.v5'].includes(version)) {
result.gallery_folders.configured = await exists('photo_categories', ['is_folder', 'event_id'], (query) =>
query.where('is_folder', formatBoolean(true)).where((q) => q.whereNull('event_id').orWhereIn('event_id', db('events').select('id'))));
result.transfer_upload_links.configured = Boolean(effective.transfers) && await exists('transfers',
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -2,10 +2,10 @@
// Vendored byte-identical in PicPeak. Existing wire versions stay immutable;
// every expansion requires explicit consent to its own version.
const CATALOG = require("./features.v4.json");
const CATALOGS = { "usage.v2": require("./features.v2.json"), "usage.v3": require("./features.v3.json"), "usage.v4": CATALOG };
const CONSENT_VERSIONS = { "usage.v1": "usage-consent.v1", "usage.v2": "usage-consent.v2", "usage.v3": "usage-consent.v3", "usage.v4": "usage-consent.v4" };
const CURRENT_SCHEMA_VERSION = "usage.v4";
const CATALOG = require("./features.v5.json");
const CATALOGS = { "usage.v2": require("./features.v2.json"), "usage.v3": require("./features.v3.json"), "usage.v4": require("./features.v4.json"), "usage.v5": CATALOG };
const CONSENT_VERSIONS = { "usage.v1": "usage-consent.v1", "usage.v2": "usage-consent.v2", "usage.v3": "usage-consent.v3", "usage.v4": "usage-consent.v4", "usage.v5": "usage-consent.v5" };
const CURRENT_SCHEMA_VERSION = "usage.v5";
const CURRENT_CONSENT_VERSION = CONSENT_VERSIONS[CURRENT_SCHEMA_VERSION];
const schemaForConsent = (consent) => Object.keys(CONSENT_VERSIONS).find((version) => CONSENT_VERSIONS[version] === consent);
const schemaRank = (version) => Object.keys(CONSENT_VERSIONS).indexOf(version);
@@ -49,7 +49,7 @@ const report = (version) => object({
key, object({ configured: boolean, ...(observesUse(key, version) ? { used: boolean } : {}) })
]))),
gallery_layouts: { type: "array", uniqueItems: true, maxItems: LAYOUTS.length, items: { enum: LAYOUTS } },
...(["usage.v3", "usage.v4"].includes(version) ? { inventory: object(Object.fromEntries(INVENTORY_KEYS.map(key => [key,
...(["usage.v3", "usage.v4", "usage.v5"].includes(version) ? { inventory: object(Object.fromEntries(INVENTORY_KEYS.map(key => [key,
{ type: "integer", minimum: 0, maximum: MAX_INVENTORY_COUNT }
]))) } : {}),
});
+146 -239
View File
@@ -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.
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.
## What the numbers mean
## 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
v3. v4 replaces `gallery_downloads` with `gallery_downloads_restricted`; the active
catalog remains at 86. 63 have configured/used booleans; 23 are configuration-only
and omit `used`. Historical views retain both questions separately (87 total keys).
ML face recognition was already included: only effective availability and a
successful authenticated admin capability operation, never biometric results.
- **Built in** is availability, never an adoption percentage. This applies to
gallery/media management, export/maintenance/archive/share/short-link tools,
restore/import, moderation, guest administration, XMP, replacement and marks,
as well as the built-in editors. An operation remains the evidence of use.
- **Enabled / configuration present** can be the shipped default, an inherited
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`:
current gallery records and non-video photo records. Counts include drafts and
retained archive records. They are not uploads, unique files or processing-success
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.
A false edit/use bit means no qualifying observation since accepted consent;
it must never be presented as proof of unchanged defaults or lifetime non-use.
Old reporters' missing new questions are unknown, not false.
No identities, business values, documents, image contents, messages, IPs, domains,
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.
## Privacy and consent
`configured` means technical availability or configuration, not evidence of use.
`used` is one monotonic bit since confirmed consent to the reporting schema. It
means successful authenticated admin capability operation, not necessarily final
completion of a queued job. New operation-specific bits use trusted success
signals in their handlers; failed operations and no-op conversions do not count.
Configuration-only signals never observe visitor/customer activity. Total photo
records can include guest uploads without observing individual upload actions.
New observations contain only fixed allowlisted booleans. The existing two
installation inventory totals (galleries and non-video photo records) are
unchanged. No content, recipient, template name/key, category/event identifiers,
logo, business value, per-action count, timestamp or content hash is stored in
usage markers or transmitted. Comparisons take place locally in the existing
admin operation; no content is scanned to reconstruct past usage.
## 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
not grant consent to v4 or start collecting the restricted-downloads signal.
- v4 asks whether at least one gallery has downloads disabled, instead of the
v2/v3 question whether at least one gallery allows them. These questions are
not complements: mixed galleries can make both true. Never invert old values.
- The EN/DE dialog explains the change and all 86 capabilities plus both totals.
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 v1v4 wire schema/catalog remains immutable. New evidence is retained only
under active, confirmed usage-consent.v5. The signed consent upgrade preserves
identity and raw history, finishes pending old packets unchanged and resets
markers only after the matching receipt. Opt-out wins over late receipts.
A marker write failure must not retry a successfully sent email.
Deploy the collector before the PicPeak client; existing clients keep working.
## Every reported capability
## Full audit
Definitions are shipped byte-identically in both applications as
`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`).
The table is the decision for every active v5 capability. The route/flag/settings
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 |
| --- | --- | --- | --- |
| `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 |
| Capability | Availability/configuration | Evidence of use |
| --- | --- | --- |
| `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. |
| `admin.js` | composition: no telemetry | Router composition / helpers; decisions are recorded for each mounted family. |
| `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. |
| `adminArchives.js` | partial: `galleries`, `archive_management`, `photo_exports` | Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded. |
| `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. |
| `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. |
| `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. |
| `adminCalendar.js` | partial: `crm`, `crm_calendar` | Authenticated admin calendar retrieval is capability use; no calendar entries, dates, recurrence, availability or bookings. |
| `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. |
| `adminCMS.js` | partial: `cms` | Admin CMS page CRUD only. Public page traffic, slug, HTML, text, links and media excluded. |
| `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. |
| `adminCssTemplates.js` | configuration: `custom_css` | Only existence of enabled applied CSS and locally observed application, not editing/viewing templates or any CSS text. |
| `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. |
| `adminDashboard.js` | partial: `analytics_dashboard` | Admin analytics capability endpoint only; no stats, activities, health/CRM polls, underlying visitor data or dashboard values. |
| `adminDatabaseBackup.js` | partial: `backup`, `database_backup` | Admin database-backup initiation plus schedule-enabled boolean, no file data/history. |
| `adminDeals.js` | partial: `crm`, `crm_installments` | Admin installment-plan changes only. No actual plans, invoice links, amounts, paid states or deal reporting. |
| `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. |
| `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. |
| `adminEventRename.js` | partial: `galleries` | Successful rename only, not validate-rename. No former/new names or identifiers. |
| `adminEvents/archiveBulk.js` | partial: `galleries`, `archive_management`, `photo_exports` | Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded. |
| `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. |
| `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. |
| `adminEvents/faces.js` | partial: `face_recognition` | Effective flag plus successful admin faces/people operation. No health polling, embeddings, names, groups, detections or visitor searches. |
| `adminEvents/helpers.js` | composition: no telemetry | Router composition / helpers; decisions are recorded for each mounted family. |
| `adminEvents/index.js` | composition: no telemetry | Router composition / helpers; decisions are recorded for each mounted family. |
| `adminEvents/logo.js` | partial: `branding` | Successful admin logo operation only; image/filename/content excluded. |
| `adminEvents/qr.js` | partial: `gallery_sharing` | Admin QR generation only; no scans, tokens or URLs. |
| `adminEvents/resets.js` | partial: `galleries`, `gallery_sharing` | Admin gallery reset/sharing capability only; no password, recipient, token or reset statistics. |
| `adminEvents/slideshow.js` | partial: `slideshow` | Admin generate/disable/configure only, never kiosk viewers or slide advances. |
| `adminEventTypes.js` | partial: `event_types` | Admin event-type CRUD; preset contents/names excluded. |
| `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. |
| `adminExternalMedia.js` | partial: `share_mounts` | Only admin import operation; status/list/browse are not use. Snapshot checks external-path presence, never reports a path. |
| `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. |
| `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. |
| `adminGuests.js` | partial: `guest_management` | Admin guest management/export initiation only. No guest names, invitations, tokens, contact data, guest counts or visitor interactions. |
| `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. |
| `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. |
| `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. |
| `adminNewsletters.js` | partial: `newsletters` | Admin campaign changes/test/queue/cancel only. Recipient resolution, previews, subscriptions/unsubscribes, delivery/open/click data and automatic sending excluded. |
| `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. |
| `adminPhotoDimensions.js` | partial: `photo_processing` | Admin repair/regenerate/configuration initiation, never status polling or processing totals. |
| `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. |
| `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. |
| `adminProjects.js` | partial: `crm`, `crm_projects` | Admin project operations only; project/person names, business performance, metadata and totals excluded. |
| `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. |
| `adminRestore.js` | partial: `restore` | Admin restore initiation only, never file selection, content, progress, errors or timing. |
| `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. |
| `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. |
| `adminShortUrls.js` | partial: `gallery_sharing`, `short_links` | Admin short-link creation/deletion only; link/token/click metadata excluded. |
| `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. |
| `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. |
| `adminTaxReport.js` | partial: `accounting`, `accounting_tax_report` | Admin tax report generation/export only; no totals, dates, tax regimes, geography or currency. |
| `adminThumbnails.js` | partial: `photo_processing` | Admin repair/regenerate/configuration initiation, never status polling or processing totals. |
| `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. |
| `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. |
| `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. |
| `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. |
| `adminWebhooks.js` | partial: `webhooks` | Active configuration existence plus successful admin manual test/replay enqueue. Actual network delivery/results/subscriptions/destinations excluded. |
| `adminWhatsapp.js` | partial: `whatsapp` | Effective configured sender and successful manual test only. No automated deliveries, phone numbers, templates or delivery statuses. |
| `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. |
| `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. |
| `auth.js` | partial: `oauth` | Only successful admin OIDC callback sets oauth. Password/gallery authentication, MFA, account claims and provider details excluded. |
| `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. |
| `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.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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
## Excluded runtime
- Gallery/customer/public events and optional website analytics
- Automated newsletter, reminder, WhatsApp, webhook and IMAP jobs
- Security/audit logs, biometric embeddings and recognition results
- 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
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `custom_css` — Custom CSS | Technical configuration exists; may be a default, not activity. | Applied CSS observed after consent, without observing visitors. |
| `oauth` Admin SSO | Technical configuration exists; may be a default, not activity. | Successful admin SSO login; no account, identity-provider or session details. |
| `smtp` — SMTP delivery | Technical configuration exists; may be a default, not activity. | A successful explicitly initiated admin SMTP test/send; no recipients or messages. |
| `whatsapp` — WhatsApp integration | Technical configuration exists; may be a default, not activity. | Successful admin integration test; no recipient, message or delivery history. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `reminder_emails` — Automatic event reminders | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `public_site` — Public landing page | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `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. |
| `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. |
| `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. |
| `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. |
| `webhooks` — Outbound webhooks | Technical configuration exists; may be a default, not activity. | Successful explicit admin webhook test/replay; no automatic or visitor-triggered deliveries. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `gallery_feedback_likes` — Gallery likes enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_feedback_ratings` — Gallery star ratings enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_feedback_comments` — Gallery comments enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_feedback_favorites` — Gallery favorites enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_feedback_reactions` — Gallery reactions enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `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_guest_accounts` — Guest identities enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_guest_uploads` — Guest uploads enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_downloads_restricted` — Gallery downloads restricted | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `download_resolution_picker` — Download resolution picker enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_client_access` — Client access enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_watermarks` — Watermarks enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_image_protection` — Image protection enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_reveal` — Gallery reveal enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `gallery_expiration` — Gallery expiration configured | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `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. |
| `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. |
| `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. |
| `gallery_folders` — Gallery folders configured | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `transfer_upload_links` — PicTransfer upload links enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `workflow_automation_enabled` — Workflow automation enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `s3_auto_import` — S3 automatic import enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `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. |
| `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_capture_date_sort` — Capture-date sorting configured | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `download_original_filenames` — Original download filenames enabled | Technical configuration exists; may be a default, not activity. | Not collected. No visitor/customer activity inferred. |
| `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. |
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { useState } from 'react';
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. */
export function UsageCatalog() {
@@ -29,7 +29,7 @@ export function UsageCatalog() {
<section key={key} className="border-t border-theme pt-2">
<h4 className="font-semibold">{t(`productUsage.catalog.${key}.name`)}</h4>
<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
? `${t('productUsage.usedLabel')}: ${t(`productUsage.catalog.${key}.used`)}`
: t('productUsage.configurationOnly')}</p>
@@ -69,10 +69,10 @@ beforeEach(() => {
};
});
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();
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.preview).not.toHaveBeenCalled();
expect(service.upgradeConsent).not.toHaveBeenCalled();
File diff suppressed because it is too large Load Diff
+44 -7
View File
@@ -1,17 +1,17 @@
{
"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.",
"catalogTitle": "Vollständiger Katalog: 86 Funktionssignale und 2 Bestandszahlen (usage.v4)",
"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: 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.",
"catalogSearch": "Funktionsname oder Schlüssel suchen",
"catalogEmpty": "Keine passenden Funktionen.",
"configuredLabel": "Konfiguriert",
"configuredLabel": "Verfügbarkeit / Konfiguration",
"usedLabel": "Genutzt",
"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}}",
"reviewUpgrade": "Erweiterten Umfang von usage.v4 ansehen",
"upgrade": "usage.v4 ausdrücklich zustimmen",
"reviewUpgrade": "Erweiterten Umfang von usage.v5 ansehen",
"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.",
"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": {
@@ -425,6 +425,41 @@
"gallery_downloads_restricted": {
"name": "Galerie-Downloads eingeschränkt",
"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",
@@ -522,7 +557,9 @@
"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."
}
}
},
"builtinLabel": "Fest integriert — Verfügbarkeit bedeutet keine Nutzung",
"enabledLabel": "Aktiviert — kann ein Standard sein"
},
"userManagement": {
"title": "Benutzerverwaltung",
+44 -7
View File
@@ -1,17 +1,17 @@
{
"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.",
"catalogTitle": "Full catalog: 86 capability signals and 2 inventory totals (usage.v4)",
"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: 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.",
"catalogSearch": "Search capability name or key",
"catalogEmpty": "No matching capabilities.",
"configuredLabel": "Configured",
"configuredLabel": "Availability / configuration",
"usedLabel": "Used",
"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}}",
"reviewUpgrade": "Review expanded usage.v4 scope",
"upgrade": "Explicitly agree to usage.v4",
"reviewUpgrade": "Review expanded usage.v5 scope",
"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.",
"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": {
@@ -425,6 +425,41 @@
"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."
},
"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",
@@ -522,7 +557,9 @@
"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."
}
}
},
"builtinLabel": "Built in — availability is not use",
"enabledLabel": "Enabled — may be a default"
},
"userManagement": {
"title": "User Management",
@@ -49,12 +49,12 @@ export const productUsageService = {
async enable(): Promise<UsageStatus> {
return (
await api.post('/admin/usage/enable', {
consent_version: 'usage-consent.v4'
consent_version: 'usage-consent.v5'
})
).data;
},
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> {
return (await api.post('/admin/usage/disable')).data;